Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- scripts/README.md +75 -0
- scripts/analyze-plugin-sdk-usage.ts +10 -0
- scripts/android-app-i18n.ts +1594 -0
- scripts/android-native-ci.mjs +58 -0
- scripts/android-pin-version.ts +25 -0
- scripts/android-screenshots.sh +770 -0
- scripts/android-sips-linux.sh +77 -0
- scripts/apple-app-i18n.ts +984 -0
- scripts/audit-seams.mts +1049 -0
- scripts/authorized-beta-focused-policy.json +77 -0
- scripts/bench-agent-concurrency-worker.ts +836 -0
- scripts/bench-agent-concurrency.ts +394 -0
- scripts/bench-cli-startup.ts +1747 -0
- scripts/bench-gateway-concurrency.ts +2569 -0
- scripts/bench-gateway-startup.ts +1119 -0
- scripts/bench-task-registry-sqlite-worker.ts +468 -0
- scripts/bench-task-registry-sqlite.ts +470 -0
- scripts/build-diffs-viewer-runtime.mts +123 -0
- scripts/build-plugin-control-ui.mts +35 -0
- scripts/bundle-a2ui.mts +9 -0
- scripts/capture-composer-mic-hover-proof.mts +97 -0
- scripts/capture-injected-turn-notice-proof.mts +117 -0
- scripts/capture-where-chip-auto-proof.mts +168 -0
- scripts/capture-workboard-ui-proof.mts +81 -0
- scripts/changed-lanes.mjs +5 -0
- scripts/check-built-plugin-control-plane-modules.mts +328 -0
- scripts/check-changed.mjs +5 -0
- scripts/check-changelog-attributions.mjs +4 -0
- scripts/check-channel-agnostic-boundaries.mts +368 -0
- scripts/check-cli-startup-memory.mjs +610 -0
- scripts/check-composite-action-input-interpolation.py +81 -0
- scripts/check-dependency-pins.mts +186 -0
- scripts/check-deprecated-jsdoc.mts +137 -0
- scripts/check-docker-e2e-boundaries.mts +182 -0
- scripts/check-docs-config-examples.mjs +7 -0
- scripts/check-docs-mdx.mts +477 -0
- scripts/check-file-utils.ts +131 -0
- scripts/check-import-cycles.ts +173 -0
- scripts/check-kysely-guardrails.mts +539 -0
- scripts/check-live-cache.ts +27 -0
- scripts/check-media-download-helper-roundtrip.mts +67 -0
- scripts/check-memory-fd-repro.mjs +5 -0
- scripts/check-native-state-schema-version.mjs +52 -0
- scripts/check-no-conflict-markers.mjs +111 -0
- scripts/check-no-deprecated-channel-access.ts +115 -0
- scripts/check-no-monolithic-plugin-sdk-entry-imports.ts +111 -0
- scripts/check-package-patches.mts +158 -0
- scripts/check-pairing-account-scope.mts +94 -0
- scripts/check-plugin-gateway-gauntlet.mts +1187 -0
- scripts/check-plugin-sdk-wildcard-reexports.mts +31 -0
scripts/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
summary: "Repository script entry points and compatibility notes"
|
| 3 |
+
read_when:
|
| 4 |
+
- Looking for an existing script before adding a new one
|
| 5 |
+
- Running repository checks, tests, docs, Docker, release, or GitHub helper scripts
|
| 6 |
+
- Updating package scripts or CI workflow script references
|
| 7 |
+
title: "Scripts Directory"
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# Scripts Directory
|
| 11 |
+
|
| 12 |
+
The `scripts/` directory contains repository tooling used by local development,
|
| 13 |
+
CI, docs publishing, releases, Docker proof, and maintainer operations. Prefer
|
| 14 |
+
the package-script entry points in `package.json` when one exists, then read the
|
| 15 |
+
underlying script before running it directly.
|
| 16 |
+
|
| 17 |
+
## Compatibility
|
| 18 |
+
|
| 19 |
+
Many scripts are stable paths referenced by `package.json`, GitHub Actions,
|
| 20 |
+
docs, and maintainer runbooks. Do not move, rename, or regroup scripts only to
|
| 21 |
+
improve taxonomy. A directory migration needs an explicit maintainer-approved
|
| 22 |
+
compatibility plan for package scripts, workflows, docs snippets, and any raw
|
| 23 |
+
script paths users may have copied.
|
| 24 |
+
|
| 25 |
+
This index is a discovery aid for the current flat layout. It does not define a
|
| 26 |
+
new directory taxonomy.
|
| 27 |
+
|
| 28 |
+
## Common Entry Points
|
| 29 |
+
|
| 30 |
+
| Area | Prefer | Notes |
|
| 31 |
+
| --------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------- |
|
| 32 |
+
| Build | `pnpm build` | Runs `scripts/build-all.mts`; use specific build scripts only when debugging a build stage. |
|
| 33 |
+
| Changed checks | `pnpm changed:lanes --json`, `pnpm check:changed` | Lane classification lives in `scripts/changed-lanes.mjs`; changed-file checks live in `scripts/check-changed.mjs`. |
|
| 34 |
+
| Docs | `pnpm docs:list`, `pnpm docs:check-mdx`, `pnpm docs:check-links` | Backed by `scripts/docs-list.js`, `scripts/check-docs-mdx.mjs`, and `scripts/docs-link-audit.mjs`. |
|
| 35 |
+
| Formatting docs | `pnpm format:docs:check` | Uses `scripts/format-docs.mts`; use write mode only when intentionally formatting docs. |
|
| 36 |
+
| Lint | `pnpm lint`, `pnpm lint:core`, `pnpm lint:all` | Wrapper scripts keep oxlint behavior aligned with repo config. |
|
| 37 |
+
| Targeted tests | `pnpm test <path-or-filter>` or `node scripts/run-vitest.mjs <path-or-filter>` | Avoid bare `vitest`; it can start watch mode. |
|
| 38 |
+
| Changed tests | `pnpm test:changed` | Uses the repo's changed-test resolver instead of a broad Vitest run. |
|
| 39 |
+
| Docker proof | `pnpm test:docker:all`, `pnpm test:docker:rerun`, `pnpm test:docker:timings` | Use the planner/rerun helpers before launching broad Docker work. |
|
| 40 |
+
| Live proof | `pnpm test:live` | Live checks require the matching environment and credentials. |
|
| 41 |
+
| Release checks | `pnpm release:check`, `pnpm release:beta`, `pnpm release:candidate` | Release scripts are maintainer workflows; read release docs before use. |
|
| 42 |
+
| GitHub reads | `scripts/gh-read` | Uses a GitHub App read token when configured, leaving normal `gh` login for writes. |
|
| 43 |
+
| Commits | `git add <files...>` then `git commit -m "<message>"` | Stage only the intended files for each commit. |
|
| 44 |
+
| Remote proof | `node scripts/crabbox-wrapper.mjs ...` | Use when the current host lacks the required isolation, OS, device, or capacity; sync each run and reuse the lease. |
|
| 45 |
+
|
| 46 |
+
## Script Families
|
| 47 |
+
|
| 48 |
+
- `check-*.mts` / `check-*.ts` / retained `check-*.mjs`: guardrails for architecture, docs, package
|
| 49 |
+
contents, boundaries, workflows, and generated artifacts.
|
| 50 |
+
- `run-*.mjs` / `run-*.mts`: stable wrappers and typed implementations for Node, Vitest,
|
| 51 |
+
oxlint, tsgo, and environment setup.
|
| 52 |
+
- `test-*.mts` / retained `test-*.mjs` / `test-*.sh` / `test-*.ts`: test planners, Docker lanes, live
|
| 53 |
+
checks, and focused validation helpers.
|
| 54 |
+
- `docs-*` and `check-docs-*`: docs listing, link auditing, MDX checks,
|
| 55 |
+
spellcheck, sync, and i18n glossary checks.
|
| 56 |
+
- `release-*`, `openclaw-npm-*`, and `plugin-*-release-*`: release preparation,
|
| 57 |
+
package verification, and publishing helpers.
|
| 58 |
+
- `docker-*`, `test-docker-*`, and `test-live-*-docker.sh`: Docker E2E planning,
|
| 59 |
+
rerun, timing, and live/package lane helpers.
|
| 60 |
+
- `gh-read*`, `label-*`, `sync-labels.ts`, and PR helpers: GitHub read,
|
| 61 |
+
labeling, and maintainer workflow support.
|
| 62 |
+
- `generate-*`, `write-*`, `copy-*`, and `sync-*`: generated docs, metadata,
|
| 63 |
+
package surfaces, and build artifact support.
|
| 64 |
+
- `lib/`: shared helpers imported by script entry points.
|
| 65 |
+
|
| 66 |
+
## Maintenance Rules
|
| 67 |
+
|
| 68 |
+
- Read `scripts/AGENTS.md` before changing scripts.
|
| 69 |
+
- Keep package scripts, generators, generated-artifact checks, docs references,
|
| 70 |
+
and workflow references aligned when touching a script path.
|
| 71 |
+
- Prefer existing wrappers instead of introducing a raw tool invocation.
|
| 72 |
+
- Add or update focused tests under `test/scripts/` when changing script
|
| 73 |
+
behavior.
|
| 74 |
+
|
| 75 |
+
See also [Scripts](https://docs.openclaw.ai/help/scripts) for public-facing script guidance.
|
scripts/analyze-plugin-sdk-usage.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Analyze Plugin Sdk Usage script supports OpenClaw repository automation.
|
| 3 |
+
import { main } from "./ts-topology.ts";
|
| 4 |
+
|
| 5 |
+
const forwardedArgs = process.argv.slice(2);
|
| 6 |
+
const normalizedArgs = forwardedArgs[0] === "--" ? forwardedArgs.slice(1) : forwardedArgs;
|
| 7 |
+
const exitCode = await main(["--scope=plugin-sdk", ...normalizedArgs]);
|
| 8 |
+
if (exitCode !== 0) {
|
| 9 |
+
process.exit(exitCode);
|
| 10 |
+
}
|
scripts/android-app-i18n.ts
ADDED
|
@@ -0,0 +1,1594 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { createHash } from "node:crypto";
|
| 2 |
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { fileURLToPath } from "node:url";
|
| 5 |
+
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
| 6 |
+
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
| 7 |
+
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
|
| 8 |
+
|
| 9 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 10 |
+
const ROOT = path.resolve(HERE, "..");
|
| 11 |
+
const ANDROID_MAIN_ROOT = path.join(ROOT, "apps", "android", "app", "src", "main");
|
| 12 |
+
const RESOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "res");
|
| 13 |
+
const SOURCE_ROOT = path.join(ANDROID_MAIN_ROOT, "java");
|
| 14 |
+
const ANDROID_PLAY_SOURCE_ROOT = path.join(ROOT, "apps", "android", "app", "src", "play", "java");
|
| 15 |
+
const ANDROID_THIRD_PARTY_ROOT = path.join(ROOT, "apps", "android", "app", "src", "thirdParty");
|
| 16 |
+
const ANDROID_THIRD_PARTY_RESOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "res");
|
| 17 |
+
const ANDROID_THIRD_PARTY_SOURCE_ROOT = path.join(ANDROID_THIRD_PARTY_ROOT, "java");
|
| 18 |
+
const ANDROID_WEAR_MAIN_ROOT = path.join(ROOT, "apps", "android", "wear", "src", "main");
|
| 19 |
+
const WEAR_RESOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "res");
|
| 20 |
+
const WEAR_SOURCE_ROOT = path.join(ANDROID_WEAR_MAIN_ROOT, "java");
|
| 21 |
+
const ANDROID_SOURCE_ROOTS = [
|
| 22 |
+
SOURCE_ROOT,
|
| 23 |
+
ANDROID_PLAY_SOURCE_ROOT,
|
| 24 |
+
ANDROID_THIRD_PARTY_SOURCE_ROOT,
|
| 25 |
+
WEAR_SOURCE_ROOT,
|
| 26 |
+
] as const;
|
| 27 |
+
const INVENTORY_PATH = path.join(ROOT, "apps", ".i18n", "native-source.json");
|
| 28 |
+
const ARTIFACT_ROOT = path.join(ROOT, "apps", ".i18n", "native");
|
| 29 |
+
const TOOL_DISPLAY_PATH = path.join(
|
| 30 |
+
ROOT,
|
| 31 |
+
"apps",
|
| 32 |
+
"shared",
|
| 33 |
+
"OpenClawKit",
|
| 34 |
+
"Sources",
|
| 35 |
+
"OpenClawKit",
|
| 36 |
+
"Resources",
|
| 37 |
+
"tool-display.json",
|
| 38 |
+
);
|
| 39 |
+
const GENERATED_KOTLIN_PATH = path.join(
|
| 40 |
+
SOURCE_ROOT,
|
| 41 |
+
"ai",
|
| 42 |
+
"openclaw",
|
| 43 |
+
"app",
|
| 44 |
+
"i18n",
|
| 45 |
+
"NativeStringResources.kt",
|
| 46 |
+
);
|
| 47 |
+
const MANAGED_PREFIX = "native_";
|
| 48 |
+
const ANDROID_QUALIFIERS: Record<string, string> = {
|
| 49 |
+
id: "in",
|
| 50 |
+
"zh-CN": "zh-rCN",
|
| 51 |
+
"zh-TW": "zh-rTW",
|
| 52 |
+
"pt-BR": "pt-rBR",
|
| 53 |
+
"ja-JP": "ja",
|
| 54 |
+
};
|
| 55 |
+
const localeDirectory = (locale: string) => `values-${ANDROID_QUALIFIERS[locale] ?? locale}`;
|
| 56 |
+
const LOCALES = ["values", ...NATIVE_I18N_LOCALES.map(localeDirectory)] as const;
|
| 57 |
+
const STRING_RE = /<string\s+name="([A-Za-z0-9_]+)"([^>]*)>([\s\S]*?)<\/string>/gu;
|
| 58 |
+
const ARRAY_RE = /<string-array\s+name="([A-Za-z0-9_]+)"[^>]*>([\s\S]*?)<\/string-array>/gu;
|
| 59 |
+
const ARRAY_ITEM_RE = /<item>([\s\S]*?)<\/item>/gu;
|
| 60 |
+
const ANDROID_REFERENCE_COMMENT_RE =
|
| 61 |
+
/("""[\s\S]*?"""|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')|<!--[\s\S]*?-->|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/gu;
|
| 62 |
+
const XML_COMMENT_RE = /<!--[\s\S]*?-->/gu;
|
| 63 |
+
const FORMAT_RE = /%\d+\$[a-z]/giu;
|
| 64 |
+
const INVALID_APOSTROPHE_RE = /(?:'|(?<!\\)')/u;
|
| 65 |
+
const GENERATED_HEADER = " <!-- Generated by scripts/android-app-i18n.ts. -->";
|
| 66 |
+
const GENERATED_KOTLIN_HEADER = "// Generated by scripts/android-app-i18n.ts. Do not edit.";
|
| 67 |
+
const THIRD_PARTY_STRINGS_FILE = "accessibility_strings.xml";
|
| 68 |
+
const THIRD_PARTY_STRINGS_REPO_PATH =
|
| 69 |
+
"apps/android/app/src/thirdParty/res/values/accessibility_strings.xml";
|
| 70 |
+
const THIRD_PARTY_GENERATED_RESOURCE_RE =
|
| 71 |
+
/^apps\/android\/app\/src\/thirdParty\/res\/values-[^/]+\/accessibility_strings\.xml$/;
|
| 72 |
+
const WEAR_STRINGS_REPO_PATH = "apps/android/wear/src/main/res/values/strings.xml";
|
| 73 |
+
const WEAR_GENERATED_RESOURCE_RE =
|
| 74 |
+
/^apps\/android\/wear\/src\/main\/res\/values-[^/]+\/strings\.xml$/;
|
| 75 |
+
|
| 76 |
+
type NativeInventoryEntry = {
|
| 77 |
+
id: string;
|
| 78 |
+
source: string;
|
| 79 |
+
sites: Array<{ kind: string; path: string }>;
|
| 80 |
+
surface: "android" | "apple";
|
| 81 |
+
};
|
| 82 |
+
|
| 83 |
+
type NativeTranslations = Record<string, string>;
|
| 84 |
+
|
| 85 |
+
type ResourceString = {
|
| 86 |
+
attrs: string;
|
| 87 |
+
key: string;
|
| 88 |
+
rawValue: string;
|
| 89 |
+
value: string;
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const GENERATED_TRANSLATION_LINT_IGNORES = [
|
| 93 |
+
"Typos",
|
| 94 |
+
"TypographyDashes",
|
| 95 |
+
"TypographyEllipsis",
|
| 96 |
+
] as const;
|
| 97 |
+
|
| 98 |
+
type TranslationContradiction = {
|
| 99 |
+
locale: string;
|
| 100 |
+
selected: string;
|
| 101 |
+
source: string;
|
| 102 |
+
translations: string[];
|
| 103 |
+
};
|
| 104 |
+
|
| 105 |
+
export type GeneratedCatalog = {
|
| 106 |
+
contradictions: TranslationContradiction[];
|
| 107 |
+
kotlin: string;
|
| 108 |
+
resources: Map<string, string>;
|
| 109 |
+
sources: Set<string>;
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
export type AndroidUiLiteralFinding = {
|
| 113 |
+
line: number;
|
| 114 |
+
path: string;
|
| 115 |
+
source: string;
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
function compareText(left: string, right: string): number {
|
| 119 |
+
return left < right ? -1 : left > right ? 1 : 0;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function decodeXml(value: string): string {
|
| 123 |
+
return value
|
| 124 |
+
.replaceAll("<", "<")
|
| 125 |
+
.replaceAll(">", ">")
|
| 126 |
+
.replaceAll(""", '"')
|
| 127 |
+
.replaceAll("&", "&")
|
| 128 |
+
.replaceAll("\\n", "\n")
|
| 129 |
+
.replaceAll("\\'", "'")
|
| 130 |
+
.replaceAll('\\"', '"')
|
| 131 |
+
.replaceAll("\\\\", "\\");
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
export function decodeAndroidResourceValue(rawValue: string): string {
|
| 135 |
+
const trimmed = rawValue.trim();
|
| 136 |
+
const unquoted =
|
| 137 |
+
trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
|
| 138 |
+
return decodeXml(unquoted);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
type KotlinInterpolation = {
|
| 142 |
+
end: number;
|
| 143 |
+
start: number;
|
| 144 |
+
value: string;
|
| 145 |
+
};
|
| 146 |
+
|
| 147 |
+
function readKotlinInterpolations(source: string): KotlinInterpolation[] | null {
|
| 148 |
+
const interpolations: KotlinInterpolation[] = [];
|
| 149 |
+
for (let index = 0; index < source.length; index += 1) {
|
| 150 |
+
if (source[index] !== "$") {
|
| 151 |
+
continue;
|
| 152 |
+
}
|
| 153 |
+
const next = source[index + 1];
|
| 154 |
+
if (next !== "{" && !/[A-Za-z_]/u.test(next ?? "")) {
|
| 155 |
+
continue;
|
| 156 |
+
}
|
| 157 |
+
const start = index;
|
| 158 |
+
if (next !== "{") {
|
| 159 |
+
index += 2;
|
| 160 |
+
while (/[A-Za-z0-9_]/u.test(source[index] ?? "")) {
|
| 161 |
+
index += 1;
|
| 162 |
+
}
|
| 163 |
+
interpolations.push({ start, end: index, value: source.slice(start, index) });
|
| 164 |
+
index -= 1;
|
| 165 |
+
continue;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
let depth = 1;
|
| 169 |
+
let quote: '"' | "'" | null = null;
|
| 170 |
+
let escaped = false;
|
| 171 |
+
for (index += 2; index < source.length; index += 1) {
|
| 172 |
+
const character = source[index];
|
| 173 |
+
if (escaped) {
|
| 174 |
+
escaped = false;
|
| 175 |
+
} else if (quote !== null && character === "\\") {
|
| 176 |
+
escaped = true;
|
| 177 |
+
} else if (character === quote) {
|
| 178 |
+
quote = null;
|
| 179 |
+
} else if (quote === null && (character === '"' || character === "'")) {
|
| 180 |
+
quote = character;
|
| 181 |
+
} else if (quote === null && character === "{") {
|
| 182 |
+
depth += 1;
|
| 183 |
+
} else if (quote === null && character === "}") {
|
| 184 |
+
depth -= 1;
|
| 185 |
+
if (depth === 0) {
|
| 186 |
+
const end = index + 1;
|
| 187 |
+
interpolations.push({ start, end, value: source.slice(start, end) });
|
| 188 |
+
break;
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
}
|
| 192 |
+
if (depth !== 0) {
|
| 193 |
+
return null;
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
return interpolations;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export function renderAndroidResourceValue(source: string, translated: string): string {
|
| 200 |
+
let rendered = translated;
|
| 201 |
+
const sourceInterpolations = readKotlinInterpolations(source);
|
| 202 |
+
const translatedInterpolations = readKotlinInterpolations(translated);
|
| 203 |
+
if (sourceInterpolations === null || translatedInterpolations === null) {
|
| 204 |
+
throw new Error(
|
| 205 |
+
`Android translation has unbalanced interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`,
|
| 206 |
+
);
|
| 207 |
+
}
|
| 208 |
+
const sourceTokens = sourceInterpolations.map((interpolation) => interpolation.value);
|
| 209 |
+
const translatedTokens = translatedInterpolations.map((interpolation) => interpolation.value);
|
| 210 |
+
const tokenCounts = (tokens: readonly string[]) => {
|
| 211 |
+
const counts = new Map<string, number>();
|
| 212 |
+
for (const token of tokens) {
|
| 213 |
+
counts.set(token, (counts.get(token) ?? 0) + 1);
|
| 214 |
+
}
|
| 215 |
+
return [...counts].toSorted(([left], [right]) => compareText(left, right));
|
| 216 |
+
};
|
| 217 |
+
if (JSON.stringify(tokenCounts(sourceTokens)) !== JSON.stringify(tokenCounts(translatedTokens))) {
|
| 218 |
+
throw new Error(
|
| 219 |
+
`Android translation changed interpolation placeholders: ${JSON.stringify(source)} -> ${JSON.stringify(translated)}`,
|
| 220 |
+
);
|
| 221 |
+
}
|
| 222 |
+
if (sourceTokens.length > 0) {
|
| 223 |
+
const sourceIndices = new Map<string, number[]>();
|
| 224 |
+
for (const [index, token] of sourceTokens.entries()) {
|
| 225 |
+
const indices = sourceIndices.get(token) ?? [];
|
| 226 |
+
indices.push(index + 1);
|
| 227 |
+
sourceIndices.set(token, indices);
|
| 228 |
+
}
|
| 229 |
+
const translatedOccurrences = new Map<string, number>();
|
| 230 |
+
let cursor = 0;
|
| 231 |
+
rendered = "";
|
| 232 |
+
for (const interpolation of translatedInterpolations) {
|
| 233 |
+
rendered += translated.slice(cursor, interpolation.start).replaceAll("%", "%%");
|
| 234 |
+
const token = interpolation.value;
|
| 235 |
+
const occurrence = translatedOccurrences.get(token) ?? 0;
|
| 236 |
+
translatedOccurrences.set(token, occurrence + 1);
|
| 237 |
+
const index = sourceIndices.get(token)?.[occurrence];
|
| 238 |
+
rendered += index ? `%${index}$s` : token;
|
| 239 |
+
cursor = interpolation.end;
|
| 240 |
+
}
|
| 241 |
+
rendered += translated.slice(cursor).replaceAll("%", "%%");
|
| 242 |
+
}
|
| 243 |
+
return escapeAndroidResourceValue(rendered);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
export function escapeAndroidResourceValue(value: string): string {
|
| 247 |
+
return value
|
| 248 |
+
.replaceAll("\\", "\\\\")
|
| 249 |
+
.replaceAll("\n", "\\n")
|
| 250 |
+
.replaceAll("'", "\\'")
|
| 251 |
+
.replaceAll('"', '\\"')
|
| 252 |
+
.replaceAll("&", "&")
|
| 253 |
+
.replaceAll("<", "<")
|
| 254 |
+
.replaceAll(">", ">");
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
function escapeKotlin(value: string): string {
|
| 258 |
+
return value
|
| 259 |
+
.replaceAll("\\", "\\\\")
|
| 260 |
+
.replaceAll('"', '\\"')
|
| 261 |
+
.replaceAll("$", "\\$")
|
| 262 |
+
.replaceAll("\n", "\\n");
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
function resourceKey(source: string): string {
|
| 266 |
+
return `${MANAGED_PREFIX}${createHash("sha256").update(source).digest("hex").slice(0, 16)}`;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
function parseStrings(source: string): ResourceString[] {
|
| 270 |
+
return [...source.matchAll(STRING_RE)].map((match) => ({
|
| 271 |
+
key: match[1] ?? "",
|
| 272 |
+
attrs: match[2] ?? "",
|
| 273 |
+
rawValue: match[3] ?? "",
|
| 274 |
+
value: decodeXml(match[3] ?? ""),
|
| 275 |
+
}));
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function withGeneratedTranslationLintIgnores(attrs: string): string {
|
| 279 |
+
const existing = attrs.match(/\btools:ignore\s*=\s*"([^"]*)"/u);
|
| 280 |
+
const ignores = [
|
| 281 |
+
...(existing?.[1]
|
| 282 |
+
?.split(",")
|
| 283 |
+
.map((value) => value.trim())
|
| 284 |
+
.filter(Boolean) ?? []),
|
| 285 |
+
...GENERATED_TRANSLATION_LINT_IGNORES,
|
| 286 |
+
];
|
| 287 |
+
const rendered = `tools:ignore="${[...new Set(ignores)].join(",")}"`;
|
| 288 |
+
return existing ? attrs.replace(existing[0], rendered) : `${attrs} ${rendered}`;
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function parseArrays(source: string): Map<string, string[]> {
|
| 292 |
+
return new Map(
|
| 293 |
+
[...source.matchAll(ARRAY_RE)].map((match) => [
|
| 294 |
+
match[1] ?? "",
|
| 295 |
+
[...(match[2] ?? "").matchAll(ARRAY_ITEM_RE)].map((item) =>
|
| 296 |
+
decodeXml((item[1] ?? "").trim()),
|
| 297 |
+
),
|
| 298 |
+
]),
|
| 299 |
+
);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
async function readStrings(
|
| 303 |
+
locale: string,
|
| 304 |
+
resourceRoot = RESOURCE_ROOT,
|
| 305 |
+
fileName = "strings.xml",
|
| 306 |
+
): Promise<Map<string, ResourceString>> {
|
| 307 |
+
const source = await readFile(path.join(resourceRoot, locale, fileName), "utf8");
|
| 308 |
+
return new Map(parseStrings(source).map((entry) => [entry.key, entry]));
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async function readAndroidSource(
|
| 312 |
+
root = SOURCE_ROOT,
|
| 313 |
+
): Promise<Array<{ path: string; source: string }>> {
|
| 314 |
+
const entries = await readdir(root, { withFileTypes: true });
|
| 315 |
+
const sources: Array<{ path: string; source: string }> = [];
|
| 316 |
+
for (const entry of entries) {
|
| 317 |
+
const fullPath = path.join(root, entry.name);
|
| 318 |
+
if (entry.isDirectory()) {
|
| 319 |
+
sources.push(...(await readAndroidSource(fullPath)));
|
| 320 |
+
continue;
|
| 321 |
+
}
|
| 322 |
+
if (entry.isFile() && entry.name.endsWith(".kt")) {
|
| 323 |
+
sources.push({
|
| 324 |
+
path: path.relative(ROOT, fullPath).split(path.sep).join("/"),
|
| 325 |
+
source: await readFile(fullPath, "utf8"),
|
| 326 |
+
});
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
return sources;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
async function readAllAndroidSource(): Promise<Array<{ path: string; source: string }>> {
|
| 333 |
+
return (await Promise.all(ANDROID_SOURCE_ROOTS.map((root) => readAndroidSource(root)))).flat();
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
type AndroidResourceReferenceSource = {
|
| 337 |
+
path: string;
|
| 338 |
+
source: string;
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
async function readAndroidResourceReferences(
|
| 342 |
+
root = ANDROID_MAIN_ROOT,
|
| 343 |
+
): Promise<AndroidResourceReferenceSource[]> {
|
| 344 |
+
const entries = await readdir(root, { withFileTypes: true });
|
| 345 |
+
const sources: AndroidResourceReferenceSource[] = [];
|
| 346 |
+
for (const entry of entries) {
|
| 347 |
+
const fullPath = path.join(root, entry.name);
|
| 348 |
+
if (entry.isDirectory()) {
|
| 349 |
+
// This walk is confined to app/src/main, so Gradle build output is never eligible.
|
| 350 |
+
sources.push(...(await readAndroidResourceReferences(fullPath)));
|
| 351 |
+
continue;
|
| 352 |
+
}
|
| 353 |
+
if (entry.isFile() && /\.(?:kt|kts|xml)$/u.test(entry.name)) {
|
| 354 |
+
sources.push({
|
| 355 |
+
path: path.relative(ROOT, fullPath).split(path.sep).join("/"),
|
| 356 |
+
source: await readFile(fullPath, "utf8"),
|
| 357 |
+
});
|
| 358 |
+
}
|
| 359 |
+
}
|
| 360 |
+
return sources;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
export function findUnusedAndroidResourceKeys(
|
| 364 |
+
keys: Iterable<string>,
|
| 365 |
+
referenceSources: readonly AndroidResourceReferenceSource[],
|
| 366 |
+
): string[] {
|
| 367 |
+
const references = new Set<string>();
|
| 368 |
+
for (const reference of referenceSources) {
|
| 369 |
+
const isXml = reference.path.endsWith(".xml");
|
| 370 |
+
const source = reference.source.replace(
|
| 371 |
+
isXml ? XML_COMMENT_RE : ANDROID_REFERENCE_COMMENT_RE,
|
| 372 |
+
(match) => match.replace(/[^\r\n]/gu, " "),
|
| 373 |
+
);
|
| 374 |
+
const pattern = isXml ? /@string\/([A-Za-z0-9_]+)\b/gu : /\bR\.string\.([A-Za-z0-9_]+)\b/gu;
|
| 375 |
+
for (const match of source.matchAll(pattern)) {
|
| 376 |
+
if (match[1]) {
|
| 377 |
+
references.add(match[1]);
|
| 378 |
+
}
|
| 379 |
+
}
|
| 380 |
+
}
|
| 381 |
+
return [...keys].filter((key) => !references.has(key));
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
function lineNumber(source: string, offset: number): number {
|
| 385 |
+
return source.slice(0, offset).split("\n").length;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function decodeKotlinLiteral(value: string): string {
|
| 389 |
+
return value.replace(
|
| 390 |
+
/\\(?:u([0-9a-fA-F]{4})|[n"$\\])/gu,
|
| 391 |
+
(escape, codeUnit: string | undefined) => {
|
| 392 |
+
if (codeUnit) {
|
| 393 |
+
return String.fromCharCode(Number.parseInt(codeUnit, 16));
|
| 394 |
+
}
|
| 395 |
+
return escape === "\\n" ? "\n" : escape.slice(1);
|
| 396 |
+
},
|
| 397 |
+
);
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function collectExplicitRuntimeSources(
|
| 401 |
+
sourceFiles: readonly { path: string; source: string }[],
|
| 402 |
+
): Set<string> {
|
| 403 |
+
const sources = new Set<string>();
|
| 404 |
+
const callPattern = /\bnativeString(?:Resource)?\(\s*"((?:\\.|[^"\\])+)"/gu;
|
| 405 |
+
for (const file of sourceFiles) {
|
| 406 |
+
if (file.path.endsWith("/i18n/NativeStringResources.kt")) {
|
| 407 |
+
continue;
|
| 408 |
+
}
|
| 409 |
+
for (const match of file.source.matchAll(callPattern)) {
|
| 410 |
+
if (match[1]) {
|
| 411 |
+
sources.add(decodeKotlinLiteral(match[1]));
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
}
|
| 415 |
+
return sources;
|
| 416 |
+
}
|
| 417 |
+
|
| 418 |
+
function collectToolDisplaySources(value: unknown, sources = new Set<string>()): Set<string> {
|
| 419 |
+
if (Array.isArray(value)) {
|
| 420 |
+
for (const item of value) {
|
| 421 |
+
collectToolDisplaySources(item, sources);
|
| 422 |
+
}
|
| 423 |
+
return sources;
|
| 424 |
+
}
|
| 425 |
+
if (value === null || typeof value !== "object") {
|
| 426 |
+
return sources;
|
| 427 |
+
}
|
| 428 |
+
for (const [key, item] of Object.entries(value)) {
|
| 429 |
+
if ((key === "title" || key === "label") && typeof item === "string") {
|
| 430 |
+
sources.add(item);
|
| 431 |
+
} else {
|
| 432 |
+
collectToolDisplaySources(item, sources);
|
| 433 |
+
}
|
| 434 |
+
}
|
| 435 |
+
return sources;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
async function readToolDisplaySources(): Promise<Set<string>> {
|
| 439 |
+
return collectToolDisplaySources(JSON.parse(await readFile(TOOL_DISPLAY_PATH, "utf8")));
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
const DIRECT_UI_LITERAL_PATTERNS = [
|
| 443 |
+
/\bText\s*\(\s*(?:text\s*=\s*)?"((?:\\.|[^"\\])+)"/gu,
|
| 444 |
+
/\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"((?:\\.|[^"\\])+)"/gsu,
|
| 445 |
+
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*"((?:\\.|[^"\\])+)"/gu,
|
| 446 |
+
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\s*"((?:\\.|[^"\\])+)"/gu,
|
| 447 |
+
/\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"((?:\\.|[^"\\])+)"/gu,
|
| 448 |
+
/\bSettingsMetric\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
|
| 449 |
+
/\bToast\.makeText\s*\([^,]+,\s*"((?:\\.|[^"\\])+)"/gu,
|
| 450 |
+
/\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"((?:\\.|[^"\\])+)"/gu,
|
| 451 |
+
/\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"((?:\\.|[^"\\])+)"/gsu,
|
| 452 |
+
] as const;
|
| 453 |
+
const DIRECT_UI_RAW_LITERAL_PATTERNS = [
|
| 454 |
+
/\bText\s*\(\s*(?:text\s*=\s*)?"""([\s\S]*?)"""/gu,
|
| 455 |
+
/\b(?:ClawPrimaryButton|ClawSecondaryButton|ClawDangerButton|ClawLinkButton)\s*\([^)]*?\btext\s*=\s*"""([\s\S]*?)"""/gsu,
|
| 456 |
+
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*"""([\s\S]*?)"""/gu,
|
| 457 |
+
/\b(?:title|subtitle|body|text|label|statusText|confirmLabel|dismissLabel|contentDescription|placeholder|onClickLabel)\s*=\s*[^,\n]*\?:\s*"""([\s\S]*?)"""/gu,
|
| 458 |
+
/\b(?:_[A-Za-z0-9_]*(?:ErrorText|StatusText)|errorText|statusText)(?:\.value)?\s*=\s*"""([\s\S]*?)"""/gu,
|
| 459 |
+
/\bSettingsMetric\s*\(\s*"""([\s\S]*?)"""/gu,
|
| 460 |
+
/\bToast\.makeText\s*\([^,]+,\s*"""([\s\S]*?)"""/gu,
|
| 461 |
+
/\bset(?:Title|Message|PositiveButton|NegativeButton|NeutralButton)\s*\(\s*"""([\s\S]*?)"""/gu,
|
| 462 |
+
/\bnativeString(?:Resource)?\([^)]*\)\s*\+\s*"""([\s\S]*?)"""/gsu,
|
| 463 |
+
] as const;
|
| 464 |
+
const UI_STRING_HELPER_NAME_RE =
|
| 465 |
+
/(?:description|detail|error|label|message|nextRun|notice|report|status|subtitle|summary|text|title)$/iu;
|
| 466 |
+
const UI_STRING_FUNCTION_RE = /\bfun\s+(?:<[^>{}]*>\s*)?([A-Za-z_][A-Za-z0-9_]*)\s*/gu;
|
| 467 |
+
const UI_STRING_PROPERTY_RE =
|
| 468 |
+
/\bval\s+(?:[A-Za-z_][A-Za-z0-9_]*\.)?([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\s*(?:\n\s*)?get\(\)\s*=\s*/gu;
|
| 469 |
+
const UI_BRANCH_LITERAL_RE = /(?:->|return|\?:)\s*"((?:\\.|[^"\\])+)"/gu;
|
| 470 |
+
const UI_ELSE_BRANCH_LITERAL_RE = /\belse\s*\{?\s*"((?:\\.|[^"\\])+)"/gu;
|
| 471 |
+
const UI_BRANCH_RAW_LITERAL_RE = /(?:->|return|\?:)\s*"""([\s\S]*?)"""/gu;
|
| 472 |
+
const UI_ELSE_BRANCH_RAW_LITERAL_RE = /\belse\s*\{?\s*"""([\s\S]*?)"""/gu;
|
| 473 |
+
const UI_MODEL_STRING_NAME_RE =
|
| 474 |
+
/^(?:contentDescription|errorText|helperText|onClickLabel|statusText)$/u;
|
| 475 |
+
const UI_MODEL_CLASS_FIELDS = new Map<string, ReadonlySet<string>>([
|
| 476 |
+
["CommandItem", new Set(["subtitle", "title"])],
|
| 477 |
+
["HomeAttentionRow", new Set(["subtitle", "title"])],
|
| 478 |
+
["OverviewMetricCardSpec", new Set(["subtitle", "title", "value"])],
|
| 479 |
+
["SettingsToggleRow", new Set(["subtitle", "title"])],
|
| 480 |
+
]);
|
| 481 |
+
const KOTLIN_STRING_LITERAL_RE = /"""([\s\S]*?)"""|"((?:\\.|[^"\\])+?)"/gu;
|
| 482 |
+
|
| 483 |
+
// These literals are either technical data or immutable source tokens localized at a typed render edge.
|
| 484 |
+
const ALLOWED_UI_LITERALS = new Map<string, ReadonlySet<string>>([
|
| 485 |
+
[
|
| 486 |
+
"*",
|
| 487 |
+
new Set([
|
| 488 |
+
"0 = exact",
|
| 489 |
+
"Cron expression, e.g. 0 9 * * *",
|
| 490 |
+
"D",
|
| 491 |
+
"Google Chat",
|
| 492 |
+
"ID",
|
| 493 |
+
"ISO time, e.g. 2026-07-09T09:30:00Z",
|
| 494 |
+
"LOG",
|
| 495 |
+
"O",
|
| 496 |
+
"OC",
|
| 497 |
+
"OK",
|
| 498 |
+
"OPENCLAW",
|
| 499 |
+
"OpenClaw",
|
| 500 |
+
"U",
|
| 501 |
+
"e.g. America/New_York",
|
| 502 |
+
"current-step-alpha",
|
| 503 |
+
"gateway-progress",
|
| 504 |
+
"iMessage",
|
| 505 |
+
"main, isolated, current, or session:<id>",
|
| 506 |
+
"n/a",
|
| 507 |
+
"openclaw gateway",
|
| 508 |
+
"openclaw qr",
|
| 509 |
+
"PTT_BUSY: previous push-to-talk turn is still finishing",
|
| 510 |
+
"WhatsApp",
|
| 511 |
+
]),
|
| 512 |
+
],
|
| 513 |
+
["apps/android/app/src/main/java/ai/openclaw/app/chat/ChatController.kt", new Set(["Off"])],
|
| 514 |
+
[
|
| 515 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/SkillWorkshopSettingsScreen.kt",
|
| 516 |
+
new Set(["all", "applied", "held", "pending", "rejected"]),
|
| 517 |
+
],
|
| 518 |
+
[
|
| 519 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/GatewayDiagnostics.kt",
|
| 520 |
+
new Set(["$versionName-dev"]),
|
| 521 |
+
],
|
| 522 |
+
[
|
| 523 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatScreen.kt",
|
| 524 |
+
// Plan checklist chrome: numeric done-counter and checkmark glyph.
|
| 525 |
+
new Set(["$completedCount/${steps.size}", "✓"]),
|
| 526 |
+
],
|
| 527 |
+
[
|
| 528 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/SettingsScreens.kt",
|
| 529 |
+
// Discovered-gateway subtitles are host:port endpoints, not translatable copy.
|
| 530 |
+
new Set(["${endpoint.host}:${endpoint.port}"]),
|
| 531 |
+
],
|
| 532 |
+
[
|
| 533 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/SidebarShell.kt",
|
| 534 |
+
// Compose animation labels are tooling identifiers, not rendered copy.
|
| 535 |
+
new Set(["sidebar-content-translation"]),
|
| 536 |
+
],
|
| 537 |
+
[
|
| 538 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatCommandControls.kt",
|
| 539 |
+
new Set(["/$name", "help"]),
|
| 540 |
+
],
|
| 541 |
+
[
|
| 542 |
+
"apps/android/app/src/main/java/ai/openclaw/app/ui/chat/ChatMessageActions.kt",
|
| 543 |
+
new Set([">", "> $line"]),
|
| 544 |
+
],
|
| 545 |
+
[
|
| 546 |
+
"apps/android/wear/src/main/java/ai/openclaw/wear/WearScreens.kt",
|
| 547 |
+
// Compose animation labels are tooling identifiers, not rendered copy.
|
| 548 |
+
new Set(["voice-swipe-hint", "voice-swipe-hint-offset"]),
|
| 549 |
+
],
|
| 550 |
+
]);
|
| 551 |
+
|
| 552 |
+
function isAllowedUiLiteral(repoPath: string, source: string): boolean {
|
| 553 |
+
return (
|
| 554 |
+
source.trim().length === 0 ||
|
| 555 |
+
ALLOWED_UI_LITERALS.get("*")?.has(source) === true ||
|
| 556 |
+
ALLOWED_UI_LITERALS.get(repoPath)?.has(source) === true
|
| 557 |
+
);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
function shouldScanUiLiterals(repoPath: string): boolean {
|
| 561 |
+
if (repoPath.endsWith("/i18n/NativeStringResources.kt")) {
|
| 562 |
+
return false;
|
| 563 |
+
}
|
| 564 |
+
if (
|
| 565 |
+
repoPath.endsWith("/AndroidScreenshotFixture.kt") ||
|
| 566 |
+
repoPath.endsWith("/WearScreenshotMode.kt")
|
| 567 |
+
) {
|
| 568 |
+
return false;
|
| 569 |
+
}
|
| 570 |
+
if (repoPath.endsWith("/ui/design/ClawComponents.kt")) {
|
| 571 |
+
return false;
|
| 572 |
+
}
|
| 573 |
+
if (repoPath.endsWith("/ui/design/OpenClawMascot.kt")) {
|
| 574 |
+
return false;
|
| 575 |
+
}
|
| 576 |
+
return (
|
| 577 |
+
repoPath.includes("/ui/") ||
|
| 578 |
+
repoPath.endsWith("/accessibility/AccessibilityDevActivity.kt") ||
|
| 579 |
+
repoPath.includes("/wear/src/main/java/ai/openclaw/wear/") ||
|
| 580 |
+
repoPath.endsWith("/MainActivity.kt") ||
|
| 581 |
+
repoPath.endsWith("/NodeRuntime.kt") ||
|
| 582 |
+
repoPath.endsWith("/PermissionRequester.kt") ||
|
| 583 |
+
repoPath.endsWith("/NodeForegroundService.kt") ||
|
| 584 |
+
repoPath.endsWith("/chat/ChatController.kt") ||
|
| 585 |
+
repoPath.endsWith("/voice/MicCaptureManager.kt") ||
|
| 586 |
+
repoPath.endsWith("/voice/TalkModeManager.kt") ||
|
| 587 |
+
repoPath.endsWith("/node/SystemHandler.kt")
|
| 588 |
+
);
|
| 589 |
+
}
|
| 590 |
+
|
| 591 |
+
function findClosingDelimiter(
|
| 592 |
+
source: string,
|
| 593 |
+
openingOffset: number,
|
| 594 |
+
opening: string,
|
| 595 |
+
closing: string,
|
| 596 |
+
): number | null {
|
| 597 |
+
let depth = 0;
|
| 598 |
+
let quoted = false;
|
| 599 |
+
let escaped = false;
|
| 600 |
+
for (let index = openingOffset; index < source.length; index += 1) {
|
| 601 |
+
const character = source[index];
|
| 602 |
+
if (escaped) {
|
| 603 |
+
escaped = false;
|
| 604 |
+
continue;
|
| 605 |
+
}
|
| 606 |
+
if (quoted && character === "\\") {
|
| 607 |
+
escaped = true;
|
| 608 |
+
continue;
|
| 609 |
+
}
|
| 610 |
+
if (character === '"') {
|
| 611 |
+
quoted = !quoted;
|
| 612 |
+
continue;
|
| 613 |
+
}
|
| 614 |
+
if (quoted) {
|
| 615 |
+
continue;
|
| 616 |
+
}
|
| 617 |
+
if (character === opening) {
|
| 618 |
+
depth += 1;
|
| 619 |
+
} else if (character === closing) {
|
| 620 |
+
depth -= 1;
|
| 621 |
+
if (depth === 0) {
|
| 622 |
+
return index;
|
| 623 |
+
}
|
| 624 |
+
}
|
| 625 |
+
}
|
| 626 |
+
return null;
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
function expressionEnd(source: string, expressionStart: number): number {
|
| 630 |
+
const cursor = source.slice(expressionStart).search(/\S/u);
|
| 631 |
+
if (cursor < 0) {
|
| 632 |
+
return source.length;
|
| 633 |
+
}
|
| 634 |
+
const start = expressionStart + cursor;
|
| 635 |
+
let quoted = false;
|
| 636 |
+
let escaped = false;
|
| 637 |
+
let lineStart = start;
|
| 638 |
+
const depths = { "(": 0, "[": 0, "{": 0 };
|
| 639 |
+
const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const;
|
| 640 |
+
for (let index = start; index < source.length; index += 1) {
|
| 641 |
+
const character = source[index];
|
| 642 |
+
if (escaped) {
|
| 643 |
+
escaped = false;
|
| 644 |
+
continue;
|
| 645 |
+
}
|
| 646 |
+
if (quoted && character === "\\") {
|
| 647 |
+
escaped = true;
|
| 648 |
+
continue;
|
| 649 |
+
}
|
| 650 |
+
if (character === '"') {
|
| 651 |
+
quoted = !quoted;
|
| 652 |
+
continue;
|
| 653 |
+
}
|
| 654 |
+
if (quoted) {
|
| 655 |
+
continue;
|
| 656 |
+
}
|
| 657 |
+
if (character === "(" || character === "[" || character === "{") {
|
| 658 |
+
depths[character] += 1;
|
| 659 |
+
continue;
|
| 660 |
+
}
|
| 661 |
+
if (character === ")" || character === "]" || character === "}") {
|
| 662 |
+
depths[closingToOpening[character]] -= 1;
|
| 663 |
+
continue;
|
| 664 |
+
}
|
| 665 |
+
if (character !== "\n" || !Object.values(depths).every((depth) => depth === 0)) {
|
| 666 |
+
continue;
|
| 667 |
+
}
|
| 668 |
+
const line = source.slice(lineStart, index).trimEnd();
|
| 669 |
+
const nextLine =
|
| 670 |
+
source
|
| 671 |
+
.slice(index + 1)
|
| 672 |
+
.match(/^[^\S\n]*([^\n]*)/u)?.[1]
|
| 673 |
+
?.trimStart() ?? "";
|
| 674 |
+
const continues =
|
| 675 |
+
line.length === 0 ||
|
| 676 |
+
/(?:\?:|[+*/%&|=.,([{])$/u.test(line) ||
|
| 677 |
+
/^(?:else\b|\?:|[+*/%&|.])/u.test(nextLine);
|
| 678 |
+
if (!continues) {
|
| 679 |
+
return index;
|
| 680 |
+
}
|
| 681 |
+
lineStart = index + 1;
|
| 682 |
+
}
|
| 683 |
+
return source.length;
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
function splitTopLevelSegments(
|
| 687 |
+
source: string,
|
| 688 |
+
start: number,
|
| 689 |
+
end: number,
|
| 690 |
+
options: { trackTypeArguments?: boolean } = {},
|
| 691 |
+
): Array<{ end: number; start: number; value: string }> {
|
| 692 |
+
const segments: Array<{ end: number; start: number; value: string }> = [];
|
| 693 |
+
let segmentStart = start;
|
| 694 |
+
let quoted = false;
|
| 695 |
+
let escaped = false;
|
| 696 |
+
let typeArgumentDepth = 0;
|
| 697 |
+
let inDefaultValue = false;
|
| 698 |
+
const depths = { "(": 0, "[": 0, "{": 0 };
|
| 699 |
+
const closingToOpening = { ")": "(", "]": "[", "}": "{" } as const;
|
| 700 |
+
for (let index = start; index < end; index += 1) {
|
| 701 |
+
const character = source[index];
|
| 702 |
+
if (escaped) {
|
| 703 |
+
escaped = false;
|
| 704 |
+
continue;
|
| 705 |
+
}
|
| 706 |
+
if (quoted && character === "\\") {
|
| 707 |
+
escaped = true;
|
| 708 |
+
continue;
|
| 709 |
+
}
|
| 710 |
+
if (character === '"') {
|
| 711 |
+
quoted = !quoted;
|
| 712 |
+
continue;
|
| 713 |
+
}
|
| 714 |
+
if (quoted) {
|
| 715 |
+
continue;
|
| 716 |
+
}
|
| 717 |
+
if (options.trackTypeArguments && !inDefaultValue && character === "<") {
|
| 718 |
+
typeArgumentDepth += 1;
|
| 719 |
+
continue;
|
| 720 |
+
}
|
| 721 |
+
if (options.trackTypeArguments && character === ">" && typeArgumentDepth > 0) {
|
| 722 |
+
typeArgumentDepth -= 1;
|
| 723 |
+
continue;
|
| 724 |
+
}
|
| 725 |
+
if (character === "(" || character === "[" || character === "{") {
|
| 726 |
+
depths[character] += 1;
|
| 727 |
+
continue;
|
| 728 |
+
}
|
| 729 |
+
if (character === ")" || character === "]" || character === "}") {
|
| 730 |
+
depths[closingToOpening[character]] -= 1;
|
| 731 |
+
continue;
|
| 732 |
+
}
|
| 733 |
+
if (
|
| 734 |
+
options.trackTypeArguments &&
|
| 735 |
+
character === "=" &&
|
| 736 |
+
typeArgumentDepth === 0 &&
|
| 737 |
+
Object.values(depths).every((depth) => depth === 0)
|
| 738 |
+
) {
|
| 739 |
+
inDefaultValue = true;
|
| 740 |
+
continue;
|
| 741 |
+
}
|
| 742 |
+
if (
|
| 743 |
+
character === "," &&
|
| 744 |
+
typeArgumentDepth === 0 &&
|
| 745 |
+
Object.values(depths).every((depth) => depth === 0)
|
| 746 |
+
) {
|
| 747 |
+
segments.push({ end: index, start: segmentStart, value: source.slice(segmentStart, index) });
|
| 748 |
+
segmentStart = index + 1;
|
| 749 |
+
inDefaultValue = false;
|
| 750 |
+
}
|
| 751 |
+
}
|
| 752 |
+
segments.push({ end, start: segmentStart, value: source.slice(segmentStart, end) });
|
| 753 |
+
return segments;
|
| 754 |
+
}
|
| 755 |
+
|
| 756 |
+
function isLocalizedLiteral(expression: string, literalOffset: number): boolean {
|
| 757 |
+
return /\bnativeString(?:Resource)?\(\s*$/u.test(expression.slice(0, literalOffset));
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
function isComparisonLiteral(expression: string, literalOffset: number): boolean {
|
| 761 |
+
return /(?:==|!=)\s*$/u.test(expression.slice(0, literalOffset));
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
function collectHelperLiteralFindings(source: string, repoPath: string): AndroidUiLiteralFinding[] {
|
| 765 |
+
const findings: AndroidUiLiteralFinding[] = [];
|
| 766 |
+
const collectRange = (name: string, start: number, end: number) => {
|
| 767 |
+
if (!UI_STRING_HELPER_NAME_RE.test(name)) {
|
| 768 |
+
return;
|
| 769 |
+
}
|
| 770 |
+
const body = source.slice(start, end);
|
| 771 |
+
const direct = body.match(/^\s*"((?:\\.|[^"\\])+)"/u);
|
| 772 |
+
const directRaw = body.match(/^\s*"""([\s\S]*?)"""/u);
|
| 773 |
+
const matches = [
|
| 774 |
+
...(direct ? [direct] : []),
|
| 775 |
+
...(directRaw ? [directRaw] : []),
|
| 776 |
+
...body.matchAll(UI_BRANCH_LITERAL_RE),
|
| 777 |
+
...body.matchAll(UI_ELSE_BRANCH_LITERAL_RE),
|
| 778 |
+
...body.matchAll(UI_BRANCH_RAW_LITERAL_RE),
|
| 779 |
+
...body.matchAll(UI_ELSE_BRANCH_RAW_LITERAL_RE),
|
| 780 |
+
];
|
| 781 |
+
const seenOffsets = new Set<number>();
|
| 782 |
+
const addLiteral = (literal: string, literalOffset: number) => {
|
| 783 |
+
if (seenOffsets.has(literalOffset)) {
|
| 784 |
+
return;
|
| 785 |
+
}
|
| 786 |
+
seenOffsets.add(literalOffset);
|
| 787 |
+
findings.push({
|
| 788 |
+
line: lineNumber(source, start + literalOffset),
|
| 789 |
+
path: repoPath,
|
| 790 |
+
source: decodeKotlinLiteral(literal),
|
| 791 |
+
});
|
| 792 |
+
};
|
| 793 |
+
for (const match of matches) {
|
| 794 |
+
const literal = match[1];
|
| 795 |
+
if (!literal) {
|
| 796 |
+
continue;
|
| 797 |
+
}
|
| 798 |
+
const delimiter = match[0]?.includes('"""') ? '"""' : '"';
|
| 799 |
+
const literalOffset = body.indexOf(`${delimiter}${literal}${delimiter}`, match.index ?? 0);
|
| 800 |
+
addLiteral(literal, Math.max(0, literalOffset));
|
| 801 |
+
}
|
| 802 |
+
for (const match of body.matchAll(/\bif\s*/gu)) {
|
| 803 |
+
let cursor = (match.index ?? 0) + match[0].length;
|
| 804 |
+
if (body[cursor] !== "(") {
|
| 805 |
+
continue;
|
| 806 |
+
}
|
| 807 |
+
const closingParen = findClosingDelimiter(body, cursor, "(", ")");
|
| 808 |
+
if (closingParen === null) {
|
| 809 |
+
continue;
|
| 810 |
+
}
|
| 811 |
+
cursor = closingParen + 1;
|
| 812 |
+
while (/\s/u.test(body[cursor] ?? "")) {
|
| 813 |
+
cursor += 1;
|
| 814 |
+
}
|
| 815 |
+
if (body[cursor] === "{") {
|
| 816 |
+
cursor += 1;
|
| 817 |
+
while (/\s/u.test(body[cursor] ?? "")) {
|
| 818 |
+
cursor += 1;
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
const expression = body.slice(cursor);
|
| 822 |
+
const literal =
|
| 823 |
+
expression.match(/^"""([\s\S]*?)"""/u)?.[1] ??
|
| 824 |
+
expression.match(/^"((?:\\.|[^"\\])+)"/u)?.[1];
|
| 825 |
+
if (literal) {
|
| 826 |
+
addLiteral(literal, cursor);
|
| 827 |
+
}
|
| 828 |
+
}
|
| 829 |
+
};
|
| 830 |
+
for (const match of source.matchAll(UI_STRING_FUNCTION_RE)) {
|
| 831 |
+
const name = match[1];
|
| 832 |
+
if (!name) {
|
| 833 |
+
continue;
|
| 834 |
+
}
|
| 835 |
+
let openingParen = (match.index ?? 0) + match[0].length;
|
| 836 |
+
while (/\s/u.test(source[openingParen] ?? "")) {
|
| 837 |
+
openingParen += 1;
|
| 838 |
+
}
|
| 839 |
+
if (source[openingParen] !== "(") {
|
| 840 |
+
continue;
|
| 841 |
+
}
|
| 842 |
+
const closingParen = findClosingDelimiter(source, openingParen, "(", ")");
|
| 843 |
+
if (closingParen === null) {
|
| 844 |
+
continue;
|
| 845 |
+
}
|
| 846 |
+
const returnType = source.slice(closingParen + 1).match(/^\s*:\s*String\s*(=|\{)/u);
|
| 847 |
+
const bodyKind = returnType?.[1];
|
| 848 |
+
if (!bodyKind || returnType?.index === undefined) {
|
| 849 |
+
continue;
|
| 850 |
+
}
|
| 851 |
+
const bodyStart = closingParen + 1 + returnType.index + returnType[0].length;
|
| 852 |
+
if (bodyKind === "{") {
|
| 853 |
+
const openingBrace = bodyStart - 1;
|
| 854 |
+
const closingBrace = findClosingDelimiter(source, openingBrace, "{", "}");
|
| 855 |
+
if (closingBrace !== null) {
|
| 856 |
+
collectRange(name, bodyStart, closingBrace);
|
| 857 |
+
}
|
| 858 |
+
} else {
|
| 859 |
+
collectRange(name, bodyStart, expressionEnd(source, bodyStart));
|
| 860 |
+
}
|
| 861 |
+
}
|
| 862 |
+
for (const match of source.matchAll(UI_STRING_PROPERTY_RE)) {
|
| 863 |
+
const name = match[1];
|
| 864 |
+
if (!name) {
|
| 865 |
+
continue;
|
| 866 |
+
}
|
| 867 |
+
const bodyStart = (match.index ?? 0) + match[0].length;
|
| 868 |
+
collectRange(name, bodyStart, expressionEnd(source, bodyStart));
|
| 869 |
+
}
|
| 870 |
+
return findings;
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
function collectTypedModelLiteralFindings(
|
| 874 |
+
source: string,
|
| 875 |
+
repoPath: string,
|
| 876 |
+
): AndroidUiLiteralFinding[] {
|
| 877 |
+
const findings: AndroidUiLiteralFinding[] = [];
|
| 878 |
+
const classPattern = /\b(?:data\s+class|class)\s+([A-Za-z_][A-Za-z0-9_]*)\s*/gu;
|
| 879 |
+
for (const declaration of source.matchAll(classPattern)) {
|
| 880 |
+
const className = declaration[1];
|
| 881 |
+
if (!className) {
|
| 882 |
+
continue;
|
| 883 |
+
}
|
| 884 |
+
let openingParen = (declaration.index ?? 0) + declaration[0].length;
|
| 885 |
+
while (/\s/u.test(source[openingParen] ?? "")) {
|
| 886 |
+
openingParen += 1;
|
| 887 |
+
}
|
| 888 |
+
if (source[openingParen] === "<") {
|
| 889 |
+
const closingTypeArguments = findClosingDelimiter(source, openingParen, "<", ">");
|
| 890 |
+
if (closingTypeArguments === null) {
|
| 891 |
+
continue;
|
| 892 |
+
}
|
| 893 |
+
openingParen = closingTypeArguments + 1;
|
| 894 |
+
while (/\s/u.test(source[openingParen] ?? "")) {
|
| 895 |
+
openingParen += 1;
|
| 896 |
+
}
|
| 897 |
+
}
|
| 898 |
+
if (source[openingParen] !== "(") {
|
| 899 |
+
continue;
|
| 900 |
+
}
|
| 901 |
+
const closingParen = findClosingDelimiter(source, openingParen, "(", ")");
|
| 902 |
+
if (closingParen === null) {
|
| 903 |
+
continue;
|
| 904 |
+
}
|
| 905 |
+
const parameters = splitTopLevelSegments(source, openingParen + 1, closingParen, {
|
| 906 |
+
trackTypeArguments: true,
|
| 907 |
+
});
|
| 908 |
+
const userFacingParameters = new Map<string, number>();
|
| 909 |
+
const classFields = UI_MODEL_CLASS_FIELDS.get(className);
|
| 910 |
+
parameters.forEach((parameter, index) => {
|
| 911 |
+
const field = parameter.value.match(
|
| 912 |
+
/\bval\s+([A-Za-z_][A-Za-z0-9_]*)\s*:\s*String\??(?=\s*(?:=|$))/u,
|
| 913 |
+
)?.[1];
|
| 914 |
+
if (field && (UI_MODEL_STRING_NAME_RE.test(field) || classFields?.has(field))) {
|
| 915 |
+
userFacingParameters.set(field, index);
|
| 916 |
+
}
|
| 917 |
+
});
|
| 918 |
+
if (userFacingParameters.size === 0) {
|
| 919 |
+
continue;
|
| 920 |
+
}
|
| 921 |
+
const callPattern = new RegExp(`\\b${className}\\b`, "gu");
|
| 922 |
+
for (const call of source.matchAll(callPattern)) {
|
| 923 |
+
let callOpening = (call.index ?? 0) + call[0].length;
|
| 924 |
+
while (/\s/u.test(source[callOpening] ?? "")) {
|
| 925 |
+
callOpening += 1;
|
| 926 |
+
}
|
| 927 |
+
if (source[callOpening] === "<") {
|
| 928 |
+
const closingTypeArguments = findClosingDelimiter(source, callOpening, "<", ">");
|
| 929 |
+
if (closingTypeArguments === null) {
|
| 930 |
+
continue;
|
| 931 |
+
}
|
| 932 |
+
callOpening = closingTypeArguments + 1;
|
| 933 |
+
while (/\s/u.test(source[callOpening] ?? "")) {
|
| 934 |
+
callOpening += 1;
|
| 935 |
+
}
|
| 936 |
+
}
|
| 937 |
+
if (source[callOpening] !== "(") {
|
| 938 |
+
continue;
|
| 939 |
+
}
|
| 940 |
+
if (callOpening === openingParen) {
|
| 941 |
+
continue;
|
| 942 |
+
}
|
| 943 |
+
const callClosing = findClosingDelimiter(source, callOpening, "(", ")");
|
| 944 |
+
if (callClosing === null) {
|
| 945 |
+
continue;
|
| 946 |
+
}
|
| 947 |
+
const argumentsList = splitTopLevelSegments(source, callOpening + 1, callClosing);
|
| 948 |
+
const namedArguments = new Map<string, (typeof argumentsList)[number]>();
|
| 949 |
+
let positionalArgumentCount = argumentsList.length;
|
| 950 |
+
argumentsList.forEach((argument, index) => {
|
| 951 |
+
const name = argument.value.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(?!=)/u)?.[1];
|
| 952 |
+
if (!name) {
|
| 953 |
+
return;
|
| 954 |
+
}
|
| 955 |
+
namedArguments.set(name, argument);
|
| 956 |
+
positionalArgumentCount = Math.min(positionalArgumentCount, index);
|
| 957 |
+
});
|
| 958 |
+
for (const [field, parameterIndex] of userFacingParameters) {
|
| 959 |
+
const argument =
|
| 960 |
+
namedArguments.get(field) ??
|
| 961 |
+
(parameterIndex < positionalArgumentCount ? argumentsList[parameterIndex] : undefined);
|
| 962 |
+
if (!argument) {
|
| 963 |
+
continue;
|
| 964 |
+
}
|
| 965 |
+
for (const literal of argument.value.matchAll(KOTLIN_STRING_LITERAL_RE)) {
|
| 966 |
+
const literalValue = literal[1] ?? literal[2];
|
| 967 |
+
if (
|
| 968 |
+
!literalValue ||
|
| 969 |
+
isLocalizedLiteral(argument.value, literal.index ?? 0) ||
|
| 970 |
+
isComparisonLiteral(argument.value, literal.index ?? 0)
|
| 971 |
+
) {
|
| 972 |
+
continue;
|
| 973 |
+
}
|
| 974 |
+
findings.push({
|
| 975 |
+
line: lineNumber(source, argument.start + (literal.index ?? 0)),
|
| 976 |
+
path: repoPath,
|
| 977 |
+
source: literal[1] === undefined ? decodeKotlinLiteral(literalValue) : literalValue,
|
| 978 |
+
});
|
| 979 |
+
}
|
| 980 |
+
}
|
| 981 |
+
}
|
| 982 |
+
}
|
| 983 |
+
return findings;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
export function findUnlocalizedAndroidUiLiterals(
|
| 987 |
+
source: string,
|
| 988 |
+
repoPath: string,
|
| 989 |
+
): AndroidUiLiteralFinding[] {
|
| 990 |
+
if (!shouldScanUiLiterals(repoPath)) {
|
| 991 |
+
return [];
|
| 992 |
+
}
|
| 993 |
+
const findings = new Map<string, AndroidUiLiteralFinding>();
|
| 994 |
+
for (const [patterns, delimiter] of [
|
| 995 |
+
[DIRECT_UI_LITERAL_PATTERNS, '"'],
|
| 996 |
+
[DIRECT_UI_RAW_LITERAL_PATTERNS, '"""'],
|
| 997 |
+
] as const) {
|
| 998 |
+
for (const pattern of patterns) {
|
| 999 |
+
pattern.lastIndex = 0;
|
| 1000 |
+
for (const match of source.matchAll(pattern)) {
|
| 1001 |
+
const literal = match[1];
|
| 1002 |
+
if (!literal) {
|
| 1003 |
+
continue;
|
| 1004 |
+
}
|
| 1005 |
+
const findingSource = delimiter === '"' ? decodeKotlinLiteral(literal) : literal;
|
| 1006 |
+
if (
|
| 1007 |
+
isAllowedUiLiteral(repoPath, findingSource) ||
|
| 1008 |
+
literal.startsWith("http") ||
|
| 1009 |
+
literal.startsWith("content://")
|
| 1010 |
+
) {
|
| 1011 |
+
continue;
|
| 1012 |
+
}
|
| 1013 |
+
const matchText = match[0] ?? "";
|
| 1014 |
+
const offset =
|
| 1015 |
+
(match.index ?? 0) + matchText.lastIndexOf(`${delimiter}${literal}${delimiter}`);
|
| 1016 |
+
const finding = {
|
| 1017 |
+
line: lineNumber(source, Math.max(0, offset)),
|
| 1018 |
+
path: repoPath,
|
| 1019 |
+
source: findingSource,
|
| 1020 |
+
};
|
| 1021 |
+
findings.set(`${finding.line}\u0000${finding.source}`, finding);
|
| 1022 |
+
}
|
| 1023 |
+
}
|
| 1024 |
+
}
|
| 1025 |
+
for (const finding of [
|
| 1026 |
+
...collectHelperLiteralFindings(source, repoPath),
|
| 1027 |
+
...collectTypedModelLiteralFindings(source, repoPath),
|
| 1028 |
+
]) {
|
| 1029 |
+
if (!isAllowedUiLiteral(repoPath, finding.source)) {
|
| 1030 |
+
findings.set(`${finding.line}\u0000${finding.source}`, finding);
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
return [...findings.values()].toSorted(
|
| 1034 |
+
(left, right) => left.line - right.line || compareText(left.source, right.source),
|
| 1035 |
+
);
|
| 1036 |
+
}
|
| 1037 |
+
|
| 1038 |
+
function findInvalidResourceSyntax(strings: ReadonlyMap<string, ResourceString>): string[] {
|
| 1039 |
+
return [...strings]
|
| 1040 |
+
.filter(([, entry]) => {
|
| 1041 |
+
const trimmed = entry.rawValue.trim();
|
| 1042 |
+
const isQuoted = trimmed.startsWith('"') && trimmed.endsWith('"');
|
| 1043 |
+
return !isQuoted && INVALID_APOSTROPHE_RE.test(trimmed);
|
| 1044 |
+
})
|
| 1045 |
+
.map(([key]) => key);
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
export function selectDeterministicTranslation(source: string, values: readonly string[]): string {
|
| 1049 |
+
const translated = values.filter((value) => value !== source);
|
| 1050 |
+
const candidates = translated.length > 0 ? translated : values;
|
| 1051 |
+
const counts = new Map<string, number>();
|
| 1052 |
+
for (const value of candidates) {
|
| 1053 |
+
counts.set(value, (counts.get(value) ?? 0) + 1);
|
| 1054 |
+
}
|
| 1055 |
+
return (
|
| 1056 |
+
[...counts]
|
| 1057 |
+
.toSorted(
|
| 1058 |
+
([left, leftCount], [right, rightCount]) =>
|
| 1059 |
+
rightCount - leftCount || compareText(left, right),
|
| 1060 |
+
)
|
| 1061 |
+
.at(0)?.[0] ?? ""
|
| 1062 |
+
);
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
export function selectGeneratedTranslation(
|
| 1066 |
+
source: string,
|
| 1067 |
+
artifactTranslations: readonly string[],
|
| 1068 |
+
existing?: { source: string; translation: string },
|
| 1069 |
+
): string {
|
| 1070 |
+
// Tool-display sources can remain live after their matching UI inventory entries are retired.
|
| 1071 |
+
// Preserve the checked-in locale value only while its English source is unchanged.
|
| 1072 |
+
const candidates =
|
| 1073 |
+
artifactTranslations.length > 0
|
| 1074 |
+
? artifactTranslations
|
| 1075 |
+
: existing?.source === source && existing.translation !== source
|
| 1076 |
+
? [existing.translation]
|
| 1077 |
+
: [];
|
| 1078 |
+
return selectDeterministicTranslation(source, candidates);
|
| 1079 |
+
}
|
| 1080 |
+
|
| 1081 |
+
async function readInventory(): Promise<NativeInventoryEntry[]> {
|
| 1082 |
+
const parsed = JSON.parse(await readFile(INVENTORY_PATH, "utf8")) as {
|
| 1083 |
+
entries?: NativeInventoryEntry[];
|
| 1084 |
+
};
|
| 1085 |
+
return (parsed.entries ?? []).filter((entry) => entry.surface === "android");
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
async function readArtifacts(): Promise<Map<string, NativeTranslations>> {
|
| 1089 |
+
return new Map(
|
| 1090 |
+
await Promise.all(
|
| 1091 |
+
NATIVE_I18N_LOCALES.map(async (locale) => {
|
| 1092 |
+
const parsed = JSON.parse(
|
| 1093 |
+
await readFile(path.join(ARTIFACT_ROOT, `${locale}.json`), "utf8"),
|
| 1094 |
+
) as { translations?: NativeTranslations };
|
| 1095 |
+
return [locale, parsed.translations ?? {}] as const;
|
| 1096 |
+
}),
|
| 1097 |
+
),
|
| 1098 |
+
);
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
function translationsBySource(
|
| 1102 |
+
inventory: readonly NativeInventoryEntry[],
|
| 1103 |
+
translationsById: Readonly<NativeTranslations>,
|
| 1104 |
+
): Map<string, string[]> {
|
| 1105 |
+
const translations = new Map<string, string[]>();
|
| 1106 |
+
for (const entry of inventory) {
|
| 1107 |
+
const translated = translationsById[entry.id];
|
| 1108 |
+
if (translated !== undefined) {
|
| 1109 |
+
translations.set(entry.source, [translated]);
|
| 1110 |
+
}
|
| 1111 |
+
}
|
| 1112 |
+
return translations;
|
| 1113 |
+
}
|
| 1114 |
+
|
| 1115 |
+
function localizeManualStrings(
|
| 1116 |
+
base: ReadonlyMap<string, ResourceString>,
|
| 1117 |
+
inventoryBySource: ReadonlyMap<string, NativeInventoryEntry>,
|
| 1118 |
+
translations: Readonly<NativeTranslations>,
|
| 1119 |
+
surface: string,
|
| 1120 |
+
): ResourceString[] {
|
| 1121 |
+
return [...base.values()].map((entry) => {
|
| 1122 |
+
const source = decodeAndroidResourceValue(entry.rawValue);
|
| 1123 |
+
const translatable = !/\btranslatable\s*=\s*"false"/u.test(entry.attrs);
|
| 1124 |
+
const inventoryEntry = inventoryBySource.get(source);
|
| 1125 |
+
if (translatable && !inventoryEntry) {
|
| 1126 |
+
throw new Error(
|
| 1127 |
+
`${surface} string is missing from native inventory: ${JSON.stringify(source)}`,
|
| 1128 |
+
);
|
| 1129 |
+
}
|
| 1130 |
+
const value = (translatable && inventoryEntry && translations[inventoryEntry.id]) || source;
|
| 1131 |
+
return {
|
| 1132 |
+
attrs: translatable ? withGeneratedTranslationLintIgnores(entry.attrs) : entry.attrs,
|
| 1133 |
+
key: entry.key,
|
| 1134 |
+
rawValue: `"${escapeAndroidResourceValue(value)}"`,
|
| 1135 |
+
value,
|
| 1136 |
+
};
|
| 1137 |
+
});
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
function renderStringsXml(
|
| 1141 |
+
manual: readonly ResourceString[],
|
| 1142 |
+
generated: ReadonlyMap<string, { source: string; value: string }>,
|
| 1143 |
+
): string {
|
| 1144 |
+
const usesToolsNamespace =
|
| 1145 |
+
generated.size > 0 || manual.some((entry) => entry.attrs.includes("tools:"));
|
| 1146 |
+
const lines = [
|
| 1147 |
+
usesToolsNamespace
|
| 1148 |
+
? '<resources xmlns:tools="http://schemas.android.com/tools">'
|
| 1149 |
+
: "<resources>",
|
| 1150 |
+
];
|
| 1151 |
+
for (const entry of manual) {
|
| 1152 |
+
lines.push(` <string name="${entry.key}"${entry.attrs}>${entry.rawValue}</string>`);
|
| 1153 |
+
}
|
| 1154 |
+
if (generated.size > 0) {
|
| 1155 |
+
lines.push(GENERATED_HEADER);
|
| 1156 |
+
for (const [key, entry] of [...generated].toSorted(([left], [right]) =>
|
| 1157 |
+
compareText(left, right),
|
| 1158 |
+
)) {
|
| 1159 |
+
const formatted =
|
| 1160 |
+
(readKotlinInterpolations(entry.source)?.length ?? 0) > 0 ? "" : ' formatted="false"';
|
| 1161 |
+
// Translation-memory text intentionally preserves technical tokens and source punctuation,
|
| 1162 |
+
// so Android's English dictionary and typography suggestions do not apply to managed keys.
|
| 1163 |
+
lines.push(
|
| 1164 |
+
` <string name="${key}"${withGeneratedTranslationLintIgnores(formatted)}>"${renderAndroidResourceValue(entry.source, entry.value)}"</string>`,
|
| 1165 |
+
);
|
| 1166 |
+
}
|
| 1167 |
+
}
|
| 1168 |
+
lines.push("</resources>", "");
|
| 1169 |
+
return lines.join("\n");
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
function renderAssistantXml(items: readonly string[]): string {
|
| 1173 |
+
return [
|
| 1174 |
+
"<resources>",
|
| 1175 |
+
' <string-array name="ask_openclaw_query_patterns">',
|
| 1176 |
+
...items.map((item) => ` <item>"${renderAndroidResourceValue(item, item)}"</item>`),
|
| 1177 |
+
" </string-array>",
|
| 1178 |
+
"</resources>",
|
| 1179 |
+
"",
|
| 1180 |
+
].join("\n");
|
| 1181 |
+
}
|
| 1182 |
+
|
| 1183 |
+
function renderKotlin(sourceToKey: ReadonlyMap<string, string>): string {
|
| 1184 |
+
const entries = [...sourceToKey].toSorted(([left], [right]) => compareText(left, right));
|
| 1185 |
+
return [
|
| 1186 |
+
GENERATED_KOTLIN_HEADER,
|
| 1187 |
+
"package ai.openclaw.app.i18n",
|
| 1188 |
+
"",
|
| 1189 |
+
"import ai.openclaw.app.R",
|
| 1190 |
+
"",
|
| 1191 |
+
"internal val nativeStringResourceIds: Map<String, Int> =",
|
| 1192 |
+
" mapOf(",
|
| 1193 |
+
...entries.map(([source, key]) => ` "${escapeKotlin(source)}" to R.string.${key},`),
|
| 1194 |
+
" )",
|
| 1195 |
+
"",
|
| 1196 |
+
].join("\n");
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
export async function buildAndroidAppI18nCatalog(): Promise<GeneratedCatalog> {
|
| 1200 |
+
const inventory = await readInventory();
|
| 1201 |
+
const [
|
| 1202 |
+
artifacts,
|
| 1203 |
+
localeStrings,
|
| 1204 |
+
thirdPartyBaseStrings,
|
| 1205 |
+
wearBaseStrings,
|
| 1206 |
+
sourceFiles,
|
| 1207 |
+
toolDisplaySources,
|
| 1208 |
+
] = await Promise.all([
|
| 1209 |
+
readArtifacts(),
|
| 1210 |
+
Promise.all(LOCALES.map((locale) => readStrings(locale))),
|
| 1211 |
+
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
| 1212 |
+
readStrings("values", WEAR_RESOURCE_ROOT),
|
| 1213 |
+
readAllAndroidSource(),
|
| 1214 |
+
readToolDisplaySources(),
|
| 1215 |
+
]);
|
| 1216 |
+
const baseStrings = expectDefined(localeStrings[0], "English Android string resources");
|
| 1217 |
+
const translatedStrings = localeStrings.slice(1);
|
| 1218 |
+
const wearInventoryBySource = new Map(
|
| 1219 |
+
inventory
|
| 1220 |
+
.filter((entry) => entry.sites.some((site) => site.path === WEAR_STRINGS_REPO_PATH))
|
| 1221 |
+
.map((entry) => [entry.source, entry]),
|
| 1222 |
+
);
|
| 1223 |
+
const thirdPartyInventoryBySource = new Map(
|
| 1224 |
+
inventory
|
| 1225 |
+
.filter((entry) => entry.sites.some((site) => site.path === THIRD_PARTY_STRINGS_REPO_PATH))
|
| 1226 |
+
.map((entry) => [entry.source, entry]),
|
| 1227 |
+
);
|
| 1228 |
+
const manualBase = [...baseStrings.values()].filter(
|
| 1229 |
+
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
| 1230 |
+
);
|
| 1231 |
+
const manualSourceToKey = new Map(manualBase.map((entry) => [entry.value, entry.key]));
|
| 1232 |
+
const entriesBySource = new Map<string, NativeInventoryEntry[]>();
|
| 1233 |
+
for (const entry of inventory) {
|
| 1234 |
+
if (
|
| 1235 |
+
entry.surface !== "android" ||
|
| 1236 |
+
entry.sites.every((site) => site.kind === "resource-string")
|
| 1237 |
+
) {
|
| 1238 |
+
continue;
|
| 1239 |
+
}
|
| 1240 |
+
const group = entriesBySource.get(entry.source) ?? [];
|
| 1241 |
+
group.push(entry);
|
| 1242 |
+
entriesBySource.set(entry.source, group);
|
| 1243 |
+
}
|
| 1244 |
+
for (const source of collectExplicitRuntimeSources(sourceFiles)) {
|
| 1245 |
+
if (!entriesBySource.has(source)) {
|
| 1246 |
+
entriesBySource.set(source, []);
|
| 1247 |
+
}
|
| 1248 |
+
}
|
| 1249 |
+
for (const source of toolDisplaySources) {
|
| 1250 |
+
if (!entriesBySource.has(source)) {
|
| 1251 |
+
entriesBySource.set(source, []);
|
| 1252 |
+
}
|
| 1253 |
+
}
|
| 1254 |
+
const sourceToKey = new Map<string, string>();
|
| 1255 |
+
for (const source of entriesBySource.keys()) {
|
| 1256 |
+
sourceToKey.set(source, manualSourceToKey.get(source) ?? resourceKey(source));
|
| 1257 |
+
}
|
| 1258 |
+
const resources = new Map<string, string>();
|
| 1259 |
+
const contradictions: TranslationContradiction[] = [];
|
| 1260 |
+
for (const [localeIndex, locale] of NATIVE_I18N_LOCALES.entries()) {
|
| 1261 |
+
const manualTranslations = translatedStrings[localeIndex] ?? new Map();
|
| 1262 |
+
const localeTranslations = artifacts.get(locale) ?? {};
|
| 1263 |
+
const artifactTranslationsBySource = translationsBySource(inventory, localeTranslations);
|
| 1264 |
+
const generated = new Map<string, { source: string; value: string }>();
|
| 1265 |
+
for (const source of entriesBySource.keys()) {
|
| 1266 |
+
const key = sourceToKey.get(source);
|
| 1267 |
+
if (!key || !key.startsWith(MANAGED_PREFIX)) {
|
| 1268 |
+
continue;
|
| 1269 |
+
}
|
| 1270 |
+
const translations = artifactTranslationsBySource.get(source) ?? [];
|
| 1271 |
+
const existingSource = decodeAndroidResourceValue(baseStrings.get(key)?.rawValue ?? "");
|
| 1272 |
+
const existingTranslation = decodeAndroidResourceValue(
|
| 1273 |
+
manualTranslations.get(key)?.rawValue ?? "",
|
| 1274 |
+
);
|
| 1275 |
+
const selected = selectGeneratedTranslation(
|
| 1276 |
+
source,
|
| 1277 |
+
translations,
|
| 1278 |
+
existingSource && existingTranslation
|
| 1279 |
+
? { source: existingSource, translation: existingTranslation }
|
| 1280 |
+
: undefined,
|
| 1281 |
+
);
|
| 1282 |
+
if (selected === source && translations.some((translation) => translation !== source)) {
|
| 1283 |
+
throw new Error(
|
| 1284 |
+
`Android translation selection kept the source despite a translated candidate: ${locale} ${JSON.stringify(source)}`,
|
| 1285 |
+
);
|
| 1286 |
+
}
|
| 1287 |
+
const unique = [...new Set(translations)].toSorted(compareText);
|
| 1288 |
+
if (unique.length > 1) {
|
| 1289 |
+
contradictions.push({ locale, selected, source, translations: unique });
|
| 1290 |
+
}
|
| 1291 |
+
generated.set(key, {
|
| 1292 |
+
source,
|
| 1293 |
+
value: selected || source,
|
| 1294 |
+
});
|
| 1295 |
+
}
|
| 1296 |
+
const manual = [...manualTranslations.values()].filter(
|
| 1297 |
+
(entry) => !entry.key.startsWith(MANAGED_PREFIX),
|
| 1298 |
+
);
|
| 1299 |
+
resources.set(
|
| 1300 |
+
path.join(RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
| 1301 |
+
renderStringsXml(manual, generated),
|
| 1302 |
+
);
|
| 1303 |
+
const wearManual = localizeManualStrings(
|
| 1304 |
+
wearBaseStrings,
|
| 1305 |
+
wearInventoryBySource,
|
| 1306 |
+
localeTranslations,
|
| 1307 |
+
"Wear",
|
| 1308 |
+
);
|
| 1309 |
+
resources.set(
|
| 1310 |
+
path.join(WEAR_RESOURCE_ROOT, localeDirectory(locale), "strings.xml"),
|
| 1311 |
+
renderStringsXml(wearManual, new Map()),
|
| 1312 |
+
);
|
| 1313 |
+
const thirdPartyManual = localizeManualStrings(
|
| 1314 |
+
thirdPartyBaseStrings,
|
| 1315 |
+
thirdPartyInventoryBySource,
|
| 1316 |
+
localeTranslations,
|
| 1317 |
+
"Android third-party",
|
| 1318 |
+
);
|
| 1319 |
+
resources.set(
|
| 1320 |
+
path.join(
|
| 1321 |
+
ANDROID_THIRD_PARTY_RESOURCE_ROOT,
|
| 1322 |
+
localeDirectory(locale),
|
| 1323 |
+
THIRD_PARTY_STRINGS_FILE,
|
| 1324 |
+
),
|
| 1325 |
+
renderStringsXml(thirdPartyManual, new Map()),
|
| 1326 |
+
);
|
| 1327 |
+
}
|
| 1328 |
+
const generatedBase = new Map<string, { source: string; value: string }>();
|
| 1329 |
+
for (const [source, key] of sourceToKey) {
|
| 1330 |
+
if (!key.startsWith(MANAGED_PREFIX)) {
|
| 1331 |
+
continue;
|
| 1332 |
+
}
|
| 1333 |
+
generatedBase.set(key, {
|
| 1334 |
+
source,
|
| 1335 |
+
value: source,
|
| 1336 |
+
});
|
| 1337 |
+
}
|
| 1338 |
+
resources.set(
|
| 1339 |
+
path.join(RESOURCE_ROOT, "values", "strings.xml"),
|
| 1340 |
+
renderStringsXml(manualBase, generatedBase),
|
| 1341 |
+
);
|
| 1342 |
+
|
| 1343 |
+
const assistantSource = await readFile(
|
| 1344 |
+
path.join(RESOURCE_ROOT, "values", "assistant.xml"),
|
| 1345 |
+
"utf8",
|
| 1346 |
+
);
|
| 1347 |
+
const assistantItems = parseArrays(assistantSource).get("ask_openclaw_query_patterns") ?? [];
|
| 1348 |
+
for (const [locale, localeTranslations] of artifacts) {
|
| 1349 |
+
const translatedBySource = translationsBySource(inventory, localeTranslations);
|
| 1350 |
+
const translatedItems = assistantItems.map(
|
| 1351 |
+
(source) =>
|
| 1352 |
+
selectDeterministicTranslation(source, translatedBySource.get(source) ?? []) || source,
|
| 1353 |
+
);
|
| 1354 |
+
resources.set(
|
| 1355 |
+
path.join(RESOURCE_ROOT, localeDirectory(locale), "assistant.xml"),
|
| 1356 |
+
renderAssistantXml(translatedItems),
|
| 1357 |
+
);
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
return {
|
| 1361 |
+
contradictions,
|
| 1362 |
+
kotlin: renderKotlin(sourceToKey),
|
| 1363 |
+
resources,
|
| 1364 |
+
sources: new Set(sourceToKey.keys()),
|
| 1365 |
+
};
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
function formatProblems(problems: Array<readonly [string, string[]]>): string {
|
| 1369 |
+
return [
|
| 1370 |
+
"Android i18n resources are out of sync.",
|
| 1371 |
+
...problems.map(([label, keys]) => `${label}=${keys.join(",") || "none"}`),
|
| 1372 |
+
].join("\n");
|
| 1373 |
+
}
|
| 1374 |
+
|
| 1375 |
+
/**
|
| 1376 |
+
* Managed app rows (native_*) and localized Wear catalogs are owned end-to-end
|
| 1377 |
+
* by the post-merge locale refresh workflow, so a source PR may legitimately
|
| 1378 |
+
* carry stale generated output. Every other delta is real drift.
|
| 1379 |
+
*/
|
| 1380 |
+
function onlyManagedRowsPending(current: string, expected: string): boolean {
|
| 1381 |
+
const currentRows = new Map(parseStrings(current).map((entry) => [entry.key, entry]));
|
| 1382 |
+
const expectedRows = new Map(parseStrings(expected).map((entry) => [entry.key, entry]));
|
| 1383 |
+
for (const [key, entry] of currentRows) {
|
| 1384 |
+
const expectedEntry = expectedRows.get(key);
|
| 1385 |
+
if (!expectedEntry) {
|
| 1386 |
+
if (!key.startsWith(MANAGED_PREFIX)) {
|
| 1387 |
+
return false;
|
| 1388 |
+
}
|
| 1389 |
+
continue;
|
| 1390 |
+
}
|
| 1391 |
+
if (expectedEntry.attrs !== entry.attrs || expectedEntry.rawValue !== entry.rawValue) {
|
| 1392 |
+
return false;
|
| 1393 |
+
}
|
| 1394 |
+
}
|
| 1395 |
+
for (const key of expectedRows.keys()) {
|
| 1396 |
+
if (!currentRows.has(key) && !key.startsWith(MANAGED_PREFIX)) {
|
| 1397 |
+
return false;
|
| 1398 |
+
}
|
| 1399 |
+
}
|
| 1400 |
+
return true;
|
| 1401 |
+
}
|
| 1402 |
+
|
| 1403 |
+
export async function syncAndroidAppI18n(
|
| 1404 |
+
options: { check?: boolean; tolerateManagedPending?: boolean } = {},
|
| 1405 |
+
) {
|
| 1406 |
+
const catalog = await buildAndroidAppI18nCatalog();
|
| 1407 |
+
const drift: string[] = [];
|
| 1408 |
+
let sawUnmanagedDrift = false;
|
| 1409 |
+
for (const [filePath, expected] of catalog.resources) {
|
| 1410 |
+
const current = await readFile(filePath, "utf8").catch(() => "");
|
| 1411 |
+
if (current === expected) {
|
| 1412 |
+
continue;
|
| 1413 |
+
}
|
| 1414 |
+
const relativeFilePath = path.relative(ROOT, filePath).split(path.sep).join("/");
|
| 1415 |
+
if (
|
| 1416 |
+
options.check &&
|
| 1417 |
+
options.tolerateManagedPending &&
|
| 1418 |
+
(WEAR_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
|
| 1419 |
+
THIRD_PARTY_GENERATED_RESOURCE_RE.test(relativeFilePath) ||
|
| 1420 |
+
(filePath.endsWith("strings.xml") && onlyManagedRowsPending(current, expected)))
|
| 1421 |
+
) {
|
| 1422 |
+
continue;
|
| 1423 |
+
}
|
| 1424 |
+
sawUnmanagedDrift = true;
|
| 1425 |
+
drift.push(relativeFilePath);
|
| 1426 |
+
if (!options.check) {
|
| 1427 |
+
await mkdir(path.dirname(filePath), { recursive: true });
|
| 1428 |
+
await writeFile(filePath, expected);
|
| 1429 |
+
}
|
| 1430 |
+
}
|
| 1431 |
+
const currentKotlin = await readFile(GENERATED_KOTLIN_PATH, "utf8").catch(() => "");
|
| 1432 |
+
if (currentKotlin !== catalog.kotlin) {
|
| 1433 |
+
// The Kotlin map derives 1:1 from the same managed catalog: tolerate it
|
| 1434 |
+
// exactly when every resource delta above was managed-pending.
|
| 1435 |
+
if (!(options.check && options.tolerateManagedPending && !sawUnmanagedDrift)) {
|
| 1436 |
+
drift.push(path.relative(ROOT, GENERATED_KOTLIN_PATH).split(path.sep).join("/"));
|
| 1437 |
+
if (!options.check) {
|
| 1438 |
+
await writeFile(GENERATED_KOTLIN_PATH, catalog.kotlin);
|
| 1439 |
+
}
|
| 1440 |
+
}
|
| 1441 |
+
}
|
| 1442 |
+
if (options.check && drift.length > 0) {
|
| 1443 |
+
throw new Error(`Android generated localization drift:\n${drift.join("\n")}`);
|
| 1444 |
+
}
|
| 1445 |
+
if (catalog.contradictions.length > 0) {
|
| 1446 |
+
const limit = 20;
|
| 1447 |
+
const visible = catalog.contradictions.slice(0, limit);
|
| 1448 |
+
const remaining = catalog.contradictions.length - visible.length;
|
| 1449 |
+
process.stderr.write(
|
| 1450 |
+
[
|
| 1451 |
+
`android-app-i18n: contradictions=${catalog.contradictions.length}`,
|
| 1452 |
+
...visible.map(
|
| 1453 |
+
(finding) =>
|
| 1454 |
+
`${finding.locale}: ${JSON.stringify(finding.source)} -> ${JSON.stringify(finding.selected)} (${finding.translations.map((translation) => JSON.stringify(translation)).join(", ")})`,
|
| 1455 |
+
),
|
| 1456 |
+
...(remaining > 0 ? [`android-app-i18n: ${remaining} more contradictions omitted`] : []),
|
| 1457 |
+
"",
|
| 1458 |
+
].join("\n"),
|
| 1459 |
+
);
|
| 1460 |
+
}
|
| 1461 |
+
return catalog;
|
| 1462 |
+
}
|
| 1463 |
+
|
| 1464 |
+
export async function verifyAndroidAppI18n() {
|
| 1465 |
+
const [
|
| 1466 |
+
sourceFiles,
|
| 1467 |
+
base,
|
| 1468 |
+
referenceSource,
|
| 1469 |
+
thirdPartyBase,
|
| 1470 |
+
thirdPartyReferenceSource,
|
| 1471 |
+
wearBase,
|
| 1472 |
+
wearReferenceSource,
|
| 1473 |
+
] = await Promise.all([
|
| 1474 |
+
readAllAndroidSource(),
|
| 1475 |
+
readStrings("values"),
|
| 1476 |
+
readAndroidResourceReferences(),
|
| 1477 |
+
readStrings("values", ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
| 1478 |
+
readAndroidResourceReferences(ANDROID_THIRD_PARTY_ROOT),
|
| 1479 |
+
readStrings("values", WEAR_RESOURCE_ROOT),
|
| 1480 |
+
readAndroidResourceReferences(ANDROID_WEAR_MAIN_ROOT),
|
| 1481 |
+
]);
|
| 1482 |
+
const baseKeys = new Set(base.keys());
|
| 1483 |
+
const thirdPartyBaseKeys = new Set(thirdPartyBase.keys());
|
| 1484 |
+
const wearBaseKeys = new Set(wearBase.keys());
|
| 1485 |
+
const problems: Array<readonly [string, string[]]> = [
|
| 1486 |
+
["App English syntax", findInvalidResourceSyntax(base)],
|
| 1487 |
+
["Third-party English syntax", findInvalidResourceSyntax(thirdPartyBase)],
|
| 1488 |
+
["Wear English syntax", findInvalidResourceSyntax(wearBase)],
|
| 1489 |
+
];
|
| 1490 |
+
const manualBaseKeys = [...baseKeys].filter((key) => !key.startsWith(MANAGED_PREFIX));
|
| 1491 |
+
problems.push([
|
| 1492 |
+
"App English unused",
|
| 1493 |
+
findUnusedAndroidResourceKeys(manualBaseKeys, referenceSource),
|
| 1494 |
+
]);
|
| 1495 |
+
problems.push([
|
| 1496 |
+
"Third-party English unused",
|
| 1497 |
+
findUnusedAndroidResourceKeys(thirdPartyBaseKeys, thirdPartyReferenceSource),
|
| 1498 |
+
]);
|
| 1499 |
+
problems.push([
|
| 1500 |
+
"Wear English unused",
|
| 1501 |
+
findUnusedAndroidResourceKeys(wearBaseKeys, wearReferenceSource),
|
| 1502 |
+
]);
|
| 1503 |
+
const uiFindings = sourceFiles.flatMap((file) =>
|
| 1504 |
+
findUnlocalizedAndroidUiLiterals(file.source, file.path),
|
| 1505 |
+
);
|
| 1506 |
+
problems.push([
|
| 1507 |
+
"Unlocalized UI literals",
|
| 1508 |
+
uiFindings.map((finding) => `${finding.path}:${finding.line}:${finding.source}`),
|
| 1509 |
+
]);
|
| 1510 |
+
if (problems.some(([, keys]) => keys.length)) {
|
| 1511 |
+
throw new Error(formatProblems(problems));
|
| 1512 |
+
}
|
| 1513 |
+
process.stdout.write(
|
| 1514 |
+
`android-app-i18n: appSourceKeys=${baseKeys.size} thirdPartySourceKeys=${thirdPartyBaseKeys.size} wearSourceKeys=${wearBaseKeys.size}\n`,
|
| 1515 |
+
);
|
| 1516 |
+
}
|
| 1517 |
+
|
| 1518 |
+
export async function checkAndroidAppI18n(options: { tolerateManagedPending?: boolean } = {}) {
|
| 1519 |
+
await verifyAndroidAppI18n();
|
| 1520 |
+
await syncAndroidAppI18n({ check: true, ...options });
|
| 1521 |
+
const localeStrings = await Promise.all(LOCALES.map((locale) => readStrings(locale)));
|
| 1522 |
+
const wearLocaleStrings = options.tolerateManagedPending
|
| 1523 |
+
? []
|
| 1524 |
+
: await Promise.all(LOCALES.map((locale) => readStrings(locale, WEAR_RESOURCE_ROOT)));
|
| 1525 |
+
const thirdPartyLocaleStrings = options.tolerateManagedPending
|
| 1526 |
+
? []
|
| 1527 |
+
: await Promise.all(
|
| 1528 |
+
LOCALES.map((locale) =>
|
| 1529 |
+
readStrings(locale, ANDROID_THIRD_PARTY_RESOURCE_ROOT, THIRD_PARTY_STRINGS_FILE),
|
| 1530 |
+
),
|
| 1531 |
+
);
|
| 1532 |
+
const base = expectDefined(localeStrings[0], "English Android string resources");
|
| 1533 |
+
const localeProblems = (
|
| 1534 |
+
surface: string,
|
| 1535 |
+
surfaceBase: ReadonlyMap<string, ResourceString>,
|
| 1536 |
+
translations: ReadonlyArray<ReadonlyMap<string, ResourceString>>,
|
| 1537 |
+
): Array<readonly [string, string[]]> => {
|
| 1538 |
+
const surfaceBaseKeys = new Set(surfaceBase.keys());
|
| 1539 |
+
return translations.flatMap((strings, index) => {
|
| 1540 |
+
const locale = NATIVE_I18N_LOCALES[index];
|
| 1541 |
+
const keys = new Set(strings.keys());
|
| 1542 |
+
const placeholderMismatches = [...surfaceBase].flatMap(([key, sourceEntry]) => {
|
| 1543 |
+
const translatedEntry = strings.get(key);
|
| 1544 |
+
if (!translatedEntry) {
|
| 1545 |
+
return [];
|
| 1546 |
+
}
|
| 1547 |
+
const expected = [...sourceEntry.rawValue.matchAll(FORMAT_RE)]
|
| 1548 |
+
.map((match) => match[0])
|
| 1549 |
+
.toSorted();
|
| 1550 |
+
const actual = [...translatedEntry.rawValue.matchAll(FORMAT_RE)]
|
| 1551 |
+
.map((match) => match[0])
|
| 1552 |
+
.toSorted();
|
| 1553 |
+
return expected.join("\u0000") === actual.join("\u0000") ? [] : [key];
|
| 1554 |
+
});
|
| 1555 |
+
return [
|
| 1556 |
+
[`${surface} ${locale} missing`, [...surfaceBaseKeys].filter((key) => !keys.has(key))],
|
| 1557 |
+
[`${surface} ${locale} extra`, [...keys].filter((key) => !surfaceBaseKeys.has(key))],
|
| 1558 |
+
[`${surface} ${locale} placeholders`, placeholderMismatches],
|
| 1559 |
+
[`${surface} ${locale} syntax`, findInvalidResourceSyntax(strings)],
|
| 1560 |
+
] as const;
|
| 1561 |
+
});
|
| 1562 |
+
};
|
| 1563 |
+
const problems = localeProblems("App", base, localeStrings.slice(1));
|
| 1564 |
+
if (thirdPartyLocaleStrings.length > 0) {
|
| 1565 |
+
const thirdPartyBase = expectDefined(
|
| 1566 |
+
thirdPartyLocaleStrings[0],
|
| 1567 |
+
"English Android third-party string resources",
|
| 1568 |
+
);
|
| 1569 |
+
problems.push(
|
| 1570 |
+
...localeProblems("Third-party", thirdPartyBase, thirdPartyLocaleStrings.slice(1)),
|
| 1571 |
+
);
|
| 1572 |
+
}
|
| 1573 |
+
if (wearLocaleStrings.length > 0) {
|
| 1574 |
+
const wearBase = expectDefined(wearLocaleStrings[0], "English Wear string resources");
|
| 1575 |
+
problems.push(...localeProblems("Wear", wearBase, wearLocaleStrings.slice(1)));
|
| 1576 |
+
}
|
| 1577 |
+
if (problems.some(([, keys]) => keys.length)) {
|
| 1578 |
+
throw new Error(formatProblems(problems));
|
| 1579 |
+
}
|
| 1580 |
+
process.stdout.write(
|
| 1581 |
+
`android-app-i18n: appKeys=${base.size} locales=${NATIVE_I18N_LOCALES.join(",")}\n`,
|
| 1582 |
+
);
|
| 1583 |
+
}
|
| 1584 |
+
|
| 1585 |
+
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
| 1586 |
+
const [command] = process.argv.slice(2);
|
| 1587 |
+
if (command === "sync") {
|
| 1588 |
+
await syncAndroidAppI18n();
|
| 1589 |
+
} else if (command === "check") {
|
| 1590 |
+
await checkAndroidAppI18n();
|
| 1591 |
+
} else {
|
| 1592 |
+
throw new Error("usage: node --import tsx scripts/android-app-i18n.ts <sync|check>");
|
| 1593 |
+
}
|
| 1594 |
+
}
|
scripts/android-native-ci.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import { readFileSync, realpathSync } from "node:fs";
|
| 4 |
+
import { validateReleaseChildRunProvenance } from "./full-release-validation-policy.mjs";
|
| 5 |
+
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
| 6 |
+
import { isRecord } from "./lib/record-shared.mjs";
|
| 7 |
+
import { runReleaseToolingGh } from "./release-tooling-identity.mjs";
|
| 8 |
+
|
| 9 |
+
export function verifyAndroidNativeCi(approval, { runGh = runReleaseToolingGh } = {}) {
|
| 10 |
+
// Stable v2026.8.2's tag-owned publisher consumes v2 approvals from full FRV.
|
| 11 |
+
// The npm-only policy requires v3 so its separate native proof survives dispatch.
|
| 12 |
+
if (approval.version === 2 && approval.nativeCi === undefined) {
|
| 13 |
+
return;
|
| 14 |
+
}
|
| 15 |
+
const nativeCi = approval.nativeCi;
|
| 16 |
+
if (
|
| 17 |
+
approval.version !== 3 ||
|
| 18 |
+
!isRecord(nativeCi) ||
|
| 19 |
+
Object.keys(nativeCi).length !== 3 ||
|
| 20 |
+
typeof nativeCi.runId !== "string" ||
|
| 21 |
+
!/^[1-9][0-9]*$/u.test(nativeCi.runId) ||
|
| 22 |
+
nativeCi.runAttempt !== 1 ||
|
| 23 |
+
typeof nativeCi.workflowRef !== "string" ||
|
| 24 |
+
nativeCi.workflowRef.length === 0
|
| 25 |
+
) {
|
| 26 |
+
throw new Error("Android approval requires an exact native CI qualification tuple.");
|
| 27 |
+
}
|
| 28 |
+
const run = JSON.parse(
|
| 29 |
+
runGh([
|
| 30 |
+
"api",
|
| 31 |
+
`repos/${approval.repository}/actions/runs/${nativeCi.runId}`,
|
| 32 |
+
"--method",
|
| 33 |
+
"GET",
|
| 34 |
+
]),
|
| 35 |
+
);
|
| 36 |
+
const proof = validateReleaseChildRunProvenance(run, {
|
| 37 |
+
key: "nativeAndroid",
|
| 38 |
+
plannedRunAttempt: nativeCi.runAttempt,
|
| 39 |
+
repository: approval.repository,
|
| 40 |
+
runId: nativeCi.runId,
|
| 41 |
+
workflow: "ci.yml",
|
| 42 |
+
displayTitle: `CI release-native-android-${approval.parentRunId}-${approval.parentRunAttempt}-${approval.targetSha}`,
|
| 43 |
+
workflowRef: nativeCi.workflowRef,
|
| 44 |
+
workflowSha: approval.parentWorkflowSha,
|
| 45 |
+
});
|
| 46 |
+
if (
|
| 47 |
+
proof.effectiveRunAttempt !== nativeCi.runAttempt ||
|
| 48 |
+
run.status !== "completed" ||
|
| 49 |
+
run.conclusion !== "success"
|
| 50 |
+
) {
|
| 51 |
+
throw new Error("Android approval requires the exact completed successful native CI attempt.");
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Isolated release harnesses can invoke the verifier through a scripts symlink.
|
| 56 |
+
if (process.argv[1] && isDirectRunUrl(realpathSync(process.argv[1]), import.meta.url)) {
|
| 57 |
+
verifyAndroidNativeCi(JSON.parse(readFileSync(process.argv[2], "utf8")));
|
| 58 |
+
}
|
scripts/android-pin-version.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Android pinning is retired; the shared mobile cutter owns release metadata.
|
| 2 |
+
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
| 3 |
+
|
| 4 |
+
const RETIRED_MESSAGE =
|
| 5 |
+
"Android version pinning is retired. Run the shared mobile cutter in scripts/mobile-release-version.ts.";
|
| 6 |
+
|
| 7 |
+
function printUsage(): void {
|
| 8 |
+
process.stdout.write(`${RETIRED_MESSAGE}\n`);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export async function main(argv: string[]): Promise<number> {
|
| 12 |
+
if (argv.includes("-h") || argv.includes("--help")) {
|
| 13 |
+
printUsage();
|
| 14 |
+
return 0;
|
| 15 |
+
}
|
| 16 |
+
process.stderr.write(`${RETIRED_MESSAGE}\n`);
|
| 17 |
+
return 1;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
| 21 |
+
const exitCode = await main(process.argv.slice(2));
|
| 22 |
+
if (exitCode !== 0) {
|
| 23 |
+
process.exit(exitCode);
|
| 24 |
+
}
|
| 25 |
+
}
|
scripts/android-screenshots.sh
ADDED
|
@@ -0,0 +1,770 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env bash
|
| 2 |
+
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
|
| 3 |
+
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
|
| 4 |
+
exec /bin/bash "$0" "$@"
|
| 5 |
+
fi
|
| 6 |
+
set -euo pipefail
|
| 7 |
+
|
| 8 |
+
usage() {
|
| 9 |
+
cat <<'EOF'
|
| 10 |
+
Usage:
|
| 11 |
+
scripts/android-screenshots.sh [--form-factor all|phone|wear] [--device <adb-serial>] [--avd <name>] [--locale en-US] [--skip-build] [--skip-install] [--keep-emulator] [--dry-run]
|
| 12 |
+
|
| 13 |
+
Builds and installs the phone and Wear OS debug apps on matching emulators,
|
| 14 |
+
launches production screens with deterministic local fixture state, and writes
|
| 15 |
+
Google Play screenshots under:
|
| 16 |
+
apps/android/fastlane/metadata/android/<locale>/images/phoneScreenshots/
|
| 17 |
+
apps/android/fastlane/metadata/android/<locale>/images/wearScreenshots/
|
| 18 |
+
|
| 19 |
+
Capture evidence is saved under:
|
| 20 |
+
.artifacts/android-screenshots/latest/
|
| 21 |
+
|
| 22 |
+
By default, the script captures both form factors using retained Pixel 2 and
|
| 23 |
+
Wear OS Large Round AVDs. Use --form-factor with --avd or --device to capture
|
| 24 |
+
one form factor on an explicitly selected emulator.
|
| 25 |
+
EOF
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
| 29 |
+
ANDROID_DIR="${ROOT_DIR}/apps/android"
|
| 30 |
+
DEFAULT_SCREENSHOT_AVD="OpenClaw_Screenshots_API36"
|
| 31 |
+
DEFAULT_SCREENSHOT_DEVICE_PROFILE="pixel_2"
|
| 32 |
+
DEFAULT_WEAR_SCREENSHOT_AVD="OpenClaw_Wear_Screenshots_API34"
|
| 33 |
+
DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE="wearos_large_round"
|
| 34 |
+
case "$(uname -m)" in
|
| 35 |
+
arm64|aarch64) DEFAULT_SCREENSHOT_ABI="arm64-v8a" ;;
|
| 36 |
+
*) DEFAULT_SCREENSHOT_ABI="x86_64" ;;
|
| 37 |
+
esac
|
| 38 |
+
DEFAULT_SCREENSHOT_SYSTEM_IMAGE="system-images;android-36;google_apis;${DEFAULT_SCREENSHOT_ABI}"
|
| 39 |
+
DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE="system-images;android-34;android-wear;${DEFAULT_SCREENSHOT_ABI}"
|
| 40 |
+
FORM_FACTOR="${ANDROID_SCREENSHOT_FORM_FACTOR:-all}"
|
| 41 |
+
LOCALE="en-US"
|
| 42 |
+
DEVICE="${ANDROID_SCREENSHOT_DEVICE:-}"
|
| 43 |
+
AVD="${ANDROID_SCREENSHOT_AVD:-$DEFAULT_SCREENSHOT_AVD}"
|
| 44 |
+
SCREENSHOT_DEVICE_PROFILE="${ANDROID_SCREENSHOT_DEVICE_PROFILE:-$DEFAULT_SCREENSHOT_DEVICE_PROFILE}"
|
| 45 |
+
SCREENSHOT_SYSTEM_IMAGE="${ANDROID_SCREENSHOT_SYSTEM_IMAGE:-$DEFAULT_SCREENSHOT_SYSTEM_IMAGE}"
|
| 46 |
+
KEEP_EMULATOR="${ANDROID_SCREENSHOT_KEEP_EMULATOR:-0}"
|
| 47 |
+
SKIP_BUILD=0
|
| 48 |
+
SKIP_INSTALL=0
|
| 49 |
+
DRY_RUN=0
|
| 50 |
+
SCENES=(home chat settings gateway voice-wake)
|
| 51 |
+
OUTPUT_TYPE="phoneScreenshots"
|
| 52 |
+
GRADLE_ASSEMBLE_TASK=":app:assemblePlayDebug"
|
| 53 |
+
APP_PACKAGE="ai.openclaw.app.debug"
|
| 54 |
+
ACTIVITY_COMPONENT="$APP_PACKAGE/ai.openclaw.app.MainActivity"
|
| 55 |
+
EMULATOR_PID=""
|
| 56 |
+
EMULATOR_LOG=""
|
| 57 |
+
STARTED_EMULATOR=0
|
| 58 |
+
ARTIFACT_ROOT="${ROOT_DIR}/.artifacts/android-screenshots/latest"
|
| 59 |
+
ARTIFACT_DIR="${ARTIFACT_ROOT}/phone"
|
| 60 |
+
SCREENSHOT_SIZE="${ANDROID_SCREENSHOT_SIZE:-1440x2560}"
|
| 61 |
+
DISPLAY_OVERRIDDEN=0
|
| 62 |
+
ORIGINAL_WM_SIZE=""
|
| 63 |
+
ORIGINAL_WM_DENSITY=""
|
| 64 |
+
SCREENSHOT_DENSITY=""
|
| 65 |
+
TIMEZONE_OVERRIDDEN=0
|
| 66 |
+
ORIGINAL_AUTO_TIME_ZONE=""
|
| 67 |
+
ORIGINAL_TIME_ZONE=""
|
| 68 |
+
DEVICE_EXPLICIT=0
|
| 69 |
+
AVD_EXPLICIT=0
|
| 70 |
+
|
| 71 |
+
while [[ $# -gt 0 ]]; do
|
| 72 |
+
case "$1" in
|
| 73 |
+
--)
|
| 74 |
+
shift
|
| 75 |
+
;;
|
| 76 |
+
--form-factor)
|
| 77 |
+
FORM_FACTOR="${2:-}"
|
| 78 |
+
shift 2
|
| 79 |
+
;;
|
| 80 |
+
--device)
|
| 81 |
+
DEVICE="${2:-}"
|
| 82 |
+
DEVICE_EXPLICIT=1
|
| 83 |
+
shift 2
|
| 84 |
+
;;
|
| 85 |
+
--avd)
|
| 86 |
+
AVD="${2:-}"
|
| 87 |
+
AVD_EXPLICIT=1
|
| 88 |
+
shift 2
|
| 89 |
+
;;
|
| 90 |
+
--locale)
|
| 91 |
+
LOCALE="${2:-}"
|
| 92 |
+
shift 2
|
| 93 |
+
;;
|
| 94 |
+
--skip-build)
|
| 95 |
+
SKIP_BUILD=1
|
| 96 |
+
shift
|
| 97 |
+
;;
|
| 98 |
+
--skip-install)
|
| 99 |
+
SKIP_INSTALL=1
|
| 100 |
+
shift
|
| 101 |
+
;;
|
| 102 |
+
--keep-emulator)
|
| 103 |
+
KEEP_EMULATOR=1
|
| 104 |
+
shift
|
| 105 |
+
;;
|
| 106 |
+
--dry-run)
|
| 107 |
+
DRY_RUN=1
|
| 108 |
+
shift
|
| 109 |
+
;;
|
| 110 |
+
-h|--help)
|
| 111 |
+
usage
|
| 112 |
+
exit 0
|
| 113 |
+
;;
|
| 114 |
+
*)
|
| 115 |
+
echo "Unknown argument: $1" >&2
|
| 116 |
+
usage
|
| 117 |
+
exit 1
|
| 118 |
+
;;
|
| 119 |
+
esac
|
| 120 |
+
done
|
| 121 |
+
|
| 122 |
+
case "$FORM_FACTOR" in
|
| 123 |
+
all)
|
| 124 |
+
if [[ "$DEVICE_EXPLICIT" == "1" || "$AVD_EXPLICIT" == "1" ]]; then
|
| 125 |
+
echo "--device and --avd require --form-factor phone or --form-factor wear." >&2
|
| 126 |
+
exit 1
|
| 127 |
+
fi
|
| 128 |
+
if [[ "$KEEP_EMULATOR" == "1" ]]; then
|
| 129 |
+
echo "--keep-emulator requires --form-factor phone or --form-factor wear." >&2
|
| 130 |
+
exit 1
|
| 131 |
+
fi
|
| 132 |
+
child_args=(--locale "$LOCALE")
|
| 133 |
+
[[ "$SKIP_BUILD" == "1" ]] && child_args+=(--skip-build)
|
| 134 |
+
[[ "$SKIP_INSTALL" == "1" ]] && child_args+=(--skip-install)
|
| 135 |
+
[[ "$DRY_RUN" == "1" ]] && child_args+=(--dry-run)
|
| 136 |
+
bash "$0" --form-factor phone "${child_args[@]}"
|
| 137 |
+
bash "$0" --form-factor wear "${child_args[@]}"
|
| 138 |
+
exit 0
|
| 139 |
+
;;
|
| 140 |
+
phone)
|
| 141 |
+
;;
|
| 142 |
+
wear)
|
| 143 |
+
if [[ "$DEVICE_EXPLICIT" != "1" ]]; then
|
| 144 |
+
DEVICE="${ANDROID_WEAR_SCREENSHOT_DEVICE:-}"
|
| 145 |
+
fi
|
| 146 |
+
if [[ "$AVD_EXPLICIT" != "1" ]]; then
|
| 147 |
+
AVD="${ANDROID_WEAR_SCREENSHOT_AVD:-$DEFAULT_WEAR_SCREENSHOT_AVD}"
|
| 148 |
+
fi
|
| 149 |
+
SCREENSHOT_DEVICE_PROFILE="${ANDROID_WEAR_SCREENSHOT_DEVICE_PROFILE:-$DEFAULT_WEAR_SCREENSHOT_DEVICE_PROFILE}"
|
| 150 |
+
SCREENSHOT_SYSTEM_IMAGE="${ANDROID_WEAR_SCREENSHOT_SYSTEM_IMAGE:-$DEFAULT_WEAR_SCREENSHOT_SYSTEM_IMAGE}"
|
| 151 |
+
SCREENSHOT_SIZE="${ANDROID_WEAR_SCREENSHOT_SIZE:-454x454}"
|
| 152 |
+
SCENES=(chat voice controls)
|
| 153 |
+
OUTPUT_TYPE="wearScreenshots"
|
| 154 |
+
GRADLE_ASSEMBLE_TASK=":wear:assembleDebug"
|
| 155 |
+
ACTIVITY_COMPONENT="$APP_PACKAGE/ai.openclaw.wear.MainActivity"
|
| 156 |
+
ARTIFACT_DIR="${ARTIFACT_ROOT}/wear"
|
| 157 |
+
;;
|
| 158 |
+
*)
|
| 159 |
+
echo "Invalid Android screenshot form factor: ${FORM_FACTOR}" >&2
|
| 160 |
+
echo "Use all, phone, or wear." >&2
|
| 161 |
+
exit 1
|
| 162 |
+
;;
|
| 163 |
+
esac
|
| 164 |
+
|
| 165 |
+
validate_locale() {
|
| 166 |
+
local locale="$1"
|
| 167 |
+
if [[ "$locale" =~ ^[A-Za-z0-9][A-Za-z0-9_-]*$ ]]; then
|
| 168 |
+
return
|
| 169 |
+
fi
|
| 170 |
+
echo "Invalid Android screenshot locale: ${locale}" >&2
|
| 171 |
+
echo "Use a locale tag like en-US or pt-BR; path separators and dot segments are not allowed." >&2
|
| 172 |
+
exit 1
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
validate_locale "$LOCALE"
|
| 176 |
+
|
| 177 |
+
if [[ ! "$SCREENSHOT_SIZE" =~ ^[0-9]+x[0-9]+$ ]]; then
|
| 178 |
+
echo "Invalid Android screenshot size: ${SCREENSHOT_SIZE}" >&2
|
| 179 |
+
echo "Use a pixel size like 1080x1920." >&2
|
| 180 |
+
exit 1
|
| 181 |
+
fi
|
| 182 |
+
SCREENSHOT_WIDTH=$((10#${SCREENSHOT_SIZE%x*}))
|
| 183 |
+
SCREENSHOT_HEIGHT=$((10#${SCREENSHOT_SIZE#*x}))
|
| 184 |
+
SCREENSHOT_MIN_DIMENSION=$((SCREENSHOT_WIDTH < SCREENSHOT_HEIGHT ? SCREENSHOT_WIDTH : SCREENSHOT_HEIGHT))
|
| 185 |
+
SCREENSHOT_MAX_DIMENSION=$((SCREENSHOT_WIDTH > SCREENSHOT_HEIGHT ? SCREENSHOT_WIDTH : SCREENSHOT_HEIGHT))
|
| 186 |
+
if (( SCREENSHOT_MIN_DIMENSION < 320 || SCREENSHOT_MAX_DIMENSION > 3840 || SCREENSHOT_MAX_DIMENSION > SCREENSHOT_MIN_DIMENSION * 2 )); then
|
| 187 |
+
echo "Android screenshot size ${SCREENSHOT_SIZE} does not meet Google Play dimension and aspect-ratio limits." >&2
|
| 188 |
+
exit 1
|
| 189 |
+
fi
|
| 190 |
+
|
| 191 |
+
cleanup_started_emulator() {
|
| 192 |
+
local stopped=0
|
| 193 |
+
|
| 194 |
+
if [[ "$STARTED_EMULATOR" != "1" || "$KEEP_EMULATOR" == "1" ]]; then
|
| 195 |
+
return
|
| 196 |
+
fi
|
| 197 |
+
if [[ -n "${ADB_BIN:-}" && -n "${ADB_SERIAL:-}" ]]; then
|
| 198 |
+
if "$ADB_BIN" -s "$ADB_SERIAL" emu kill >/dev/null 2>&1; then
|
| 199 |
+
stopped=1
|
| 200 |
+
local deadline=$((SECONDS + 30))
|
| 201 |
+
while (( SECONDS < deadline )); do
|
| 202 |
+
if ! "$ADB_BIN" devices | awk 'NR > 1 && $2 == "device" { print $1 }' | grep -Fxq "$ADB_SERIAL"; then
|
| 203 |
+
break
|
| 204 |
+
fi
|
| 205 |
+
sleep 1
|
| 206 |
+
done
|
| 207 |
+
fi
|
| 208 |
+
fi
|
| 209 |
+
if [[ "$stopped" != "1" && -n "$EMULATOR_PID" ]]; then
|
| 210 |
+
kill "$EMULATOR_PID" >/dev/null 2>&1 || true
|
| 211 |
+
fi
|
| 212 |
+
}
|
| 213 |
+
|
| 214 |
+
restore_device_display() {
|
| 215 |
+
if [[ "$DISPLAY_OVERRIDDEN" != "1" || -z "${ADB_BIN:-}" || -z "${ADB_SERIAL:-}" ]]; then
|
| 216 |
+
return
|
| 217 |
+
fi
|
| 218 |
+
if [[ -n "$ORIGINAL_WM_SIZE" ]]; then
|
| 219 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell wm size "$ORIGINAL_WM_SIZE" >/dev/null 2>&1 || true
|
| 220 |
+
else
|
| 221 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell wm size reset >/dev/null 2>&1 || true
|
| 222 |
+
fi
|
| 223 |
+
if [[ -n "$ORIGINAL_WM_DENSITY" ]]; then
|
| 224 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell wm density "$ORIGINAL_WM_DENSITY" >/dev/null 2>&1 || true
|
| 225 |
+
else
|
| 226 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell wm density reset >/dev/null 2>&1 || true
|
| 227 |
+
fi
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
restore_device_timezone() {
|
| 231 |
+
if [[ "$TIMEZONE_OVERRIDDEN" != "1" || -z "${ADB_BIN:-}" || -z "${ADB_SERIAL:-}" ]]; then
|
| 232 |
+
return
|
| 233 |
+
fi
|
| 234 |
+
if [[ -n "$ORIGINAL_TIME_ZONE" ]]; then
|
| 235 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell cmd alarm set-timezone "$ORIGINAL_TIME_ZONE" >/dev/null 2>&1 || true
|
| 236 |
+
fi
|
| 237 |
+
if [[ "$ORIGINAL_AUTO_TIME_ZONE" == "true" || "$ORIGINAL_AUTO_TIME_ZONE" == "false" ]]; then
|
| 238 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell cmd time_zone_detector set_auto_detection_enabled "$ORIGINAL_AUTO_TIME_ZONE" >/dev/null 2>&1 || true
|
| 239 |
+
fi
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
cleanup_emulator_log() {
|
| 243 |
+
if [[ -n "$EMULATOR_LOG" && -f "$EMULATOR_LOG" ]]; then
|
| 244 |
+
rm -f "$EMULATOR_LOG"
|
| 245 |
+
fi
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
cleanup() {
|
| 249 |
+
restore_device_display
|
| 250 |
+
restore_device_timezone
|
| 251 |
+
cleanup_started_emulator
|
| 252 |
+
cleanup_emulator_log
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
trap cleanup EXIT
|
| 256 |
+
|
| 257 |
+
adb_bin() {
|
| 258 |
+
if [[ -n "${ADB:-}" ]]; then
|
| 259 |
+
printf '%s\n' "$ADB"
|
| 260 |
+
return
|
| 261 |
+
fi
|
| 262 |
+
if command -v adb >/dev/null 2>&1; then
|
| 263 |
+
command -v adb
|
| 264 |
+
return
|
| 265 |
+
fi
|
| 266 |
+
for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do
|
| 267 |
+
if [[ -n "$sdk_root" && -x "$sdk_root/platform-tools/adb" ]]; then
|
| 268 |
+
printf '%s\n' "$sdk_root/platform-tools/adb"
|
| 269 |
+
return
|
| 270 |
+
fi
|
| 271 |
+
done
|
| 272 |
+
echo "adb not found. Install Android platform-tools or set ADB." >&2
|
| 273 |
+
return 127
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
emulator_bin() {
|
| 277 |
+
if [[ -n "${ANDROID_EMULATOR:-}" ]]; then
|
| 278 |
+
printf '%s\n' "$ANDROID_EMULATOR"
|
| 279 |
+
return
|
| 280 |
+
fi
|
| 281 |
+
if command -v emulator >/dev/null 2>&1; then
|
| 282 |
+
command -v emulator
|
| 283 |
+
return
|
| 284 |
+
fi
|
| 285 |
+
for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do
|
| 286 |
+
if [[ -n "$sdk_root" && -x "$sdk_root/emulator/emulator" ]]; then
|
| 287 |
+
printf '%s\n' "$sdk_root/emulator/emulator"
|
| 288 |
+
return
|
| 289 |
+
fi
|
| 290 |
+
done
|
| 291 |
+
echo "Android emulator binary not found. Install the Android emulator or set ANDROID_EMULATOR." >&2
|
| 292 |
+
return 127
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
avdmanager_bin() {
|
| 296 |
+
if [[ -n "${AVDMANAGER:-}" ]]; then
|
| 297 |
+
printf '%s\n' "$AVDMANAGER"
|
| 298 |
+
return
|
| 299 |
+
fi
|
| 300 |
+
for sdk_root in "${ANDROID_HOME:-}" "${ANDROID_SDK_ROOT:-}" "$HOME/Library/Android/sdk"; do
|
| 301 |
+
for relative_path in cmdline-tools/latest/bin/avdmanager cmdline-tools/bin/avdmanager tools/bin/avdmanager; do
|
| 302 |
+
if [[ -n "$sdk_root" && -x "$sdk_root/$relative_path" ]]; then
|
| 303 |
+
printf '%s\n' "$sdk_root/$relative_path"
|
| 304 |
+
return
|
| 305 |
+
fi
|
| 306 |
+
done
|
| 307 |
+
done
|
| 308 |
+
if command -v avdmanager >/dev/null 2>&1; then
|
| 309 |
+
command -v avdmanager
|
| 310 |
+
return
|
| 311 |
+
fi
|
| 312 |
+
echo "avdmanager not found. Install Android SDK command-line tools or set AVDMANAGER." >&2
|
| 313 |
+
return 127
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
ensure_screenshot_avd() {
|
| 317 |
+
local avd="$1"
|
| 318 |
+
local emulator
|
| 319 |
+
local avdmanager
|
| 320 |
+
|
| 321 |
+
emulator="$(emulator_bin)"
|
| 322 |
+
if "$emulator" -list-avds | grep -Fxq "$avd"; then
|
| 323 |
+
return
|
| 324 |
+
fi
|
| 325 |
+
|
| 326 |
+
avdmanager="$(avdmanager_bin)"
|
| 327 |
+
echo "Creating no-cutout screenshot AVD '${avd}' from device profile '${SCREENSHOT_DEVICE_PROFILE}'." >&2
|
| 328 |
+
if ! printf 'no\n' | "$avdmanager" create avd \
|
| 329 |
+
--force \
|
| 330 |
+
--name "$avd" \
|
| 331 |
+
--package "$SCREENSHOT_SYSTEM_IMAGE" \
|
| 332 |
+
--device "$SCREENSHOT_DEVICE_PROFILE"; then
|
| 333 |
+
echo "Could not create Android screenshot AVD '${avd}'." >&2
|
| 334 |
+
echo "Install SDK package '${SCREENSHOT_SYSTEM_IMAGE}' or set ANDROID_SCREENSHOT_SYSTEM_IMAGE." >&2
|
| 335 |
+
return 1
|
| 336 |
+
fi
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
connected_devices() {
|
| 340 |
+
local adb="$1"
|
| 341 |
+
"$adb" devices | awk 'NR > 1 && $2 == "device" { print $1 }'
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
device_count() {
|
| 345 |
+
local devices="$1"
|
| 346 |
+
printf '%s\n' "$devices" | sed '/^$/d' | wc -l | tr -d ' '
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
running_avd_name() {
|
| 350 |
+
local adb="$1"
|
| 351 |
+
local serial="$2"
|
| 352 |
+
"$adb" -s "$serial" emu avd name 2>/dev/null | tr -d '\r' | sed -n '1p'
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
wait_for_single_device() {
|
| 356 |
+
local adb="$1"
|
| 357 |
+
local timeout_seconds="${ANDROID_SCREENSHOT_EMULATOR_TIMEOUT_SECONDS:-180}"
|
| 358 |
+
local deadline=$((SECONDS + timeout_seconds))
|
| 359 |
+
local devices
|
| 360 |
+
local count
|
| 361 |
+
|
| 362 |
+
while (( SECONDS < deadline )); do
|
| 363 |
+
devices="$(connected_devices "$adb")"
|
| 364 |
+
count="$(device_count "$devices")"
|
| 365 |
+
if [[ "$count" == "1" ]]; then
|
| 366 |
+
printf '%s\n' "$devices"
|
| 367 |
+
return
|
| 368 |
+
fi
|
| 369 |
+
sleep 2
|
| 370 |
+
done
|
| 371 |
+
|
| 372 |
+
echo "Timed out waiting for exactly one Android emulator device." >&2
|
| 373 |
+
"$adb" devices -l >&2 || true
|
| 374 |
+
return 1
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
+
wait_for_boot_completed() {
|
| 378 |
+
local adb="$1"
|
| 379 |
+
local serial="$2"
|
| 380 |
+
local timeout_seconds="${ANDROID_SCREENSHOT_EMULATOR_TIMEOUT_SECONDS:-180}"
|
| 381 |
+
local deadline=$((SECONDS + timeout_seconds))
|
| 382 |
+
local boot_completed
|
| 383 |
+
|
| 384 |
+
"$adb" -s "$serial" wait-for-device
|
| 385 |
+
while (( SECONDS < deadline )); do
|
| 386 |
+
boot_completed="$("$adb" -s "$serial" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' || true)"
|
| 387 |
+
if [[ "$boot_completed" == "1" ]]; then
|
| 388 |
+
"$adb" -s "$serial" shell input keyevent 82 >/dev/null 2>&1 || true
|
| 389 |
+
return
|
| 390 |
+
fi
|
| 391 |
+
sleep 2
|
| 392 |
+
done
|
| 393 |
+
|
| 394 |
+
echo "Timed out waiting for Android emulator boot completion on ${serial}." >&2
|
| 395 |
+
return 1
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
wait_for_explicit_device() {
|
| 399 |
+
local adb="$1"
|
| 400 |
+
local serial="$2"
|
| 401 |
+
local timeout_seconds="${ANDROID_SCREENSHOT_DEVICE_TIMEOUT_SECONDS:-30}"
|
| 402 |
+
local deadline=$((SECONDS + timeout_seconds))
|
| 403 |
+
local state
|
| 404 |
+
|
| 405 |
+
while (( SECONDS < deadline )); do
|
| 406 |
+
state="$("$adb" devices | awk -v serial="$serial" '$1 == serial { print $2 }')"
|
| 407 |
+
if [[ "$state" == "device" ]]; then
|
| 408 |
+
return
|
| 409 |
+
fi
|
| 410 |
+
sleep 2
|
| 411 |
+
done
|
| 412 |
+
|
| 413 |
+
if [[ -n "$state" ]]; then
|
| 414 |
+
echo "Android device '${serial}' did not become usable within ${timeout_seconds}s; current adb state is '${state}'." >&2
|
| 415 |
+
else
|
| 416 |
+
echo "Android device '${serial}' was not found within ${timeout_seconds}s." >&2
|
| 417 |
+
fi
|
| 418 |
+
"$adb" devices -l >&2 || true
|
| 419 |
+
return 1
|
| 420 |
+
}
|
| 421 |
+
|
| 422 |
+
stabilize_device_for_screenshots() {
|
| 423 |
+
local adb="$1"
|
| 424 |
+
local serial="$2"
|
| 425 |
+
ORIGINAL_AUTO_TIME_ZONE="$("$adb" -s "$serial" shell cmd time_zone_detector is_auto_detection_enabled 2>/dev/null | tr -d '\r')"
|
| 426 |
+
ORIGINAL_TIME_ZONE="$("$adb" -s "$serial" shell getprop persist.sys.timezone 2>/dev/null | tr -d '\r')"
|
| 427 |
+
if [[ "$ORIGINAL_AUTO_TIME_ZONE" != "true" && "$ORIGINAL_AUTO_TIME_ZONE" != "false" ]]; then
|
| 428 |
+
echo "Could not determine emulator automatic timezone setting." >&2
|
| 429 |
+
return 1
|
| 430 |
+
fi
|
| 431 |
+
if [[ -z "$ORIGINAL_TIME_ZONE" ]]; then
|
| 432 |
+
echo "Could not determine emulator timezone." >&2
|
| 433 |
+
return 1
|
| 434 |
+
fi
|
| 435 |
+
# Arm cleanup before the first mutation; restoring an unchanged snapshot is safe.
|
| 436 |
+
TIMEZONE_OVERRIDDEN=1
|
| 437 |
+
# Seeded chat timestamps use the device timezone, so disable detection before
|
| 438 |
+
# pinning UTC to avoid inheriting the runner's locale.
|
| 439 |
+
"$adb" -s "$serial" shell cmd time_zone_detector set_auto_detection_enabled false >/dev/null
|
| 440 |
+
"$adb" -s "$serial" shell cmd alarm set-timezone UTC >/dev/null
|
| 441 |
+
"$adb" -s "$serial" shell settings put global window_animation_scale 0 >/dev/null 2>&1 || true
|
| 442 |
+
"$adb" -s "$serial" shell settings put global transition_animation_scale 0 >/dev/null 2>&1 || true
|
| 443 |
+
"$adb" -s "$serial" shell settings put global animator_duration_scale 0 >/dev/null 2>&1 || true
|
| 444 |
+
"$adb" -s "$serial" shell settings put global stay_on_while_plugged_in 3 >/dev/null 2>&1 || true
|
| 445 |
+
"$adb" -s "$serial" shell svc power stayon true >/dev/null 2>&1 || true
|
| 446 |
+
"$adb" -s "$serial" shell input keyevent 224 >/dev/null 2>&1 || true
|
| 447 |
+
"$adb" -s "$serial" shell wm dismiss-keyguard >/dev/null 2>&1 || true
|
| 448 |
+
"$adb" -s "$serial" shell settings put system font_scale 1.0 >/dev/null 2>&1 || true
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
require_emulator_device() {
|
| 452 |
+
local adb="$1"
|
| 453 |
+
local serial="$2"
|
| 454 |
+
local qemu
|
| 455 |
+
|
| 456 |
+
qemu="$("$adb" -s "$serial" shell getprop ro.kernel.qemu 2>/dev/null | tr -d '\r')"
|
| 457 |
+
if [[ "$qemu" == "1" ]]; then
|
| 458 |
+
return
|
| 459 |
+
fi
|
| 460 |
+
echo "Android screenshot capture requires an emulator; '${serial}' is not an emulator." >&2
|
| 461 |
+
echo "Pass --avd <name> or --device <emulator-serial>." >&2
|
| 462 |
+
return 1
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
configure_screenshot_display() {
|
| 466 |
+
local adb="$1"
|
| 467 |
+
local serial="$2"
|
| 468 |
+
local current_size
|
| 469 |
+
local current_density
|
| 470 |
+
local effective_size
|
| 471 |
+
local effective_density
|
| 472 |
+
local effective_width
|
| 473 |
+
|
| 474 |
+
current_size="$("$adb" -s "$serial" shell wm size 2>/dev/null | tr -d '\r')"
|
| 475 |
+
current_density="$("$adb" -s "$serial" shell wm density 2>/dev/null | tr -d '\r')"
|
| 476 |
+
ORIGINAL_WM_SIZE="$(printf '%s\n' "$current_size" | sed -n 's/^Override size: //p')"
|
| 477 |
+
ORIGINAL_WM_DENSITY="$(printf '%s\n' "$current_density" | sed -n 's/^Override density: //p')"
|
| 478 |
+
effective_size="${ORIGINAL_WM_SIZE:-$(printf '%s\n' "$current_size" | sed -n 's/^Physical size: //p')}"
|
| 479 |
+
effective_density="${ORIGINAL_WM_DENSITY:-$(printf '%s\n' "$current_density" | sed -n 's/^Physical density: //p')}"
|
| 480 |
+
if [[ ! "$effective_size" =~ ^[0-9]+x[0-9]+$ || ! "$effective_density" =~ ^[0-9]+$ ]]; then
|
| 481 |
+
echo "Could not determine emulator display size and density." >&2
|
| 482 |
+
return 1
|
| 483 |
+
fi
|
| 484 |
+
|
| 485 |
+
effective_width=$((10#${effective_size%x*}))
|
| 486 |
+
SCREENSHOT_DENSITY=$(((10#$effective_density * SCREENSHOT_WIDTH + effective_width / 2) / effective_width))
|
| 487 |
+
DISPLAY_OVERRIDDEN=1
|
| 488 |
+
"$adb" -s "$serial" shell wm size "$SCREENSHOT_SIZE" >/dev/null
|
| 489 |
+
"$adb" -s "$serial" shell wm density "$SCREENSHOT_DENSITY" >/dev/null
|
| 490 |
+
echo "Android screenshot density: ${SCREENSHOT_DENSITY} dpi"
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
boot_emulator() {
|
| 494 |
+
local adb="$1"
|
| 495 |
+
local avd="$2"
|
| 496 |
+
local emulator
|
| 497 |
+
local emulator_args
|
| 498 |
+
local extra_args
|
| 499 |
+
local serial
|
| 500 |
+
|
| 501 |
+
ensure_screenshot_avd "$avd"
|
| 502 |
+
emulator="$(emulator_bin)"
|
| 503 |
+
EMULATOR_LOG="$(mktemp "${TMPDIR:-/tmp}/openclaw-android-screenshot-emulator.XXXXXX.log")"
|
| 504 |
+
echo "No connected Android device found. Booting AVD '${avd}'." >&2
|
| 505 |
+
emulator_args=(-avd "$avd" -no-window -no-audio -no-boot-anim)
|
| 506 |
+
if [[ -n "${ANDROID_SCREENSHOT_EMULATOR_ARGS:-}" ]]; then
|
| 507 |
+
read -r -a extra_args <<<"$ANDROID_SCREENSHOT_EMULATOR_ARGS"
|
| 508 |
+
emulator_args+=(${extra_args[@]+"${extra_args[@]}"})
|
| 509 |
+
fi
|
| 510 |
+
"$emulator" "${emulator_args[@]}" >"$EMULATOR_LOG" 2>&1 &
|
| 511 |
+
EMULATOR_PID="$!"
|
| 512 |
+
STARTED_EMULATOR=1
|
| 513 |
+
|
| 514 |
+
serial="$(wait_for_single_device "$adb")"
|
| 515 |
+
wait_for_boot_completed "$adb" "$serial"
|
| 516 |
+
ADB_SERIAL="$serial"
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
resolve_device() {
|
| 520 |
+
local adb="$1"
|
| 521 |
+
local devices
|
| 522 |
+
local count
|
| 523 |
+
local connected_avd
|
| 524 |
+
|
| 525 |
+
if [[ -n "$DEVICE" ]]; then
|
| 526 |
+
wait_for_explicit_device "$adb" "$DEVICE"
|
| 527 |
+
ADB_SERIAL="$DEVICE"
|
| 528 |
+
return
|
| 529 |
+
fi
|
| 530 |
+
devices="$(connected_devices "$adb")"
|
| 531 |
+
count="$(device_count "$devices")"
|
| 532 |
+
if [[ "$count" == "1" ]]; then
|
| 533 |
+
if connected_avd="$(running_avd_name "$adb" "$devices")"; then
|
| 534 |
+
if [[ "$connected_avd" != "$AVD" ]]; then
|
| 535 |
+
echo "Connected emulator '${connected_avd:-unknown}' is not the screenshot AVD '${AVD}'." >&2
|
| 536 |
+
echo "Stop it so the script can boot '${AVD}', or pass --device '${devices}' to override the no-cutout profile." >&2
|
| 537 |
+
return 1
|
| 538 |
+
fi
|
| 539 |
+
fi
|
| 540 |
+
ADB_SERIAL="$devices"
|
| 541 |
+
return
|
| 542 |
+
fi
|
| 543 |
+
if [[ "$count" == "0" ]]; then
|
| 544 |
+
boot_emulator "$adb" "$AVD"
|
| 545 |
+
return
|
| 546 |
+
else
|
| 547 |
+
echo "Multiple Android devices are connected. Pass --device <adb-serial>." >&2
|
| 548 |
+
fi
|
| 549 |
+
return 1
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
latest_debug_apk() {
|
| 553 |
+
local output_dir
|
| 554 |
+
local pattern
|
| 555 |
+
|
| 556 |
+
if [[ "$FORM_FACTOR" == "wear" ]]; then
|
| 557 |
+
output_dir="${ANDROID_DIR}/wear/build/outputs/apk/debug"
|
| 558 |
+
pattern='*-debug.apk'
|
| 559 |
+
else
|
| 560 |
+
output_dir="${ANDROID_DIR}/app/build/outputs/apk/play/debug"
|
| 561 |
+
pattern='*-play-debug.apk'
|
| 562 |
+
fi
|
| 563 |
+
if [[ ! -d "$output_dir" ]]; then
|
| 564 |
+
return 0
|
| 565 |
+
fi
|
| 566 |
+
find "$output_dir" -maxdepth 1 -name "$pattern" -print 2>/dev/null | sort | tail -n 1
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
scene_ready_text() {
|
| 570 |
+
if [[ "$FORM_FACTOR" == "wear" ]]; then
|
| 571 |
+
case "$1" in
|
| 572 |
+
chat) printf '%s\n' "Release planning" ;;
|
| 573 |
+
voice) printf '%s\n' "Dictate" ;;
|
| 574 |
+
controls) printf '%s\n' "Gateway connected" ;;
|
| 575 |
+
*)
|
| 576 |
+
echo "Unknown Wear OS screenshot scene: $1" >&2
|
| 577 |
+
return 1
|
| 578 |
+
;;
|
| 579 |
+
esac
|
| 580 |
+
return
|
| 581 |
+
fi
|
| 582 |
+
case "$1" in
|
| 583 |
+
home) printf '%s\n' "Overview" ;;
|
| 584 |
+
# The screenshot fixture seeds chat history and restores at the live edge,
|
| 585 |
+
# so wait for the latest reply instead of empty-chat copy.
|
| 586 |
+
chat) printf '%s\n' "The Android release is close." ;;
|
| 587 |
+
settings) printf '%s\n' "OpenClaw mobile" ;;
|
| 588 |
+
voice-wake) printf '%s\n' "Wake listener" ;;
|
| 589 |
+
# Connected fixtures can push Add Gateway below the composed viewport, so
|
| 590 |
+
# wait for the gateway detail's always-visible subtitle instead.
|
| 591 |
+
gateway) printf '%s\n' "Connection between this phone and OpenClaw." ;;
|
| 592 |
+
*)
|
| 593 |
+
echo "Unknown Android screenshot scene: $1" >&2
|
| 594 |
+
return 1
|
| 595 |
+
;;
|
| 596 |
+
esac
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
wait_for_scene_ready() {
|
| 600 |
+
local adb="$1"
|
| 601 |
+
local serial="$2"
|
| 602 |
+
local scene="$3"
|
| 603 |
+
local dump_path="$4"
|
| 604 |
+
local marker
|
| 605 |
+
local timeout_seconds="${ANDROID_SCREENSHOT_SCENE_TIMEOUT_SECONDS:-45}"
|
| 606 |
+
local deadline=$((SECONDS + timeout_seconds))
|
| 607 |
+
|
| 608 |
+
marker="$(scene_ready_text "$scene")"
|
| 609 |
+
while (( SECONDS < deadline )); do
|
| 610 |
+
if "$adb" -s "$serial" exec-out uiautomator dump /dev/tty >"$dump_path" 2>/dev/null; then
|
| 611 |
+
# A newly created Wear AVD may cover the resumed app with its charging
|
| 612 |
+
# overlay after stay-awake is enabled. Dismiss it or first-run capture stalls.
|
| 613 |
+
if [[ "$FORM_FACTOR" == "wear" ]] && grep -Fq 'com.google.android.wearable.sysui:id/charging_container' "$dump_path"; then
|
| 614 |
+
"$adb" -s "$serial" shell input keyevent 4 >/dev/null 2>&1 || true
|
| 615 |
+
sleep 0.5
|
| 616 |
+
continue
|
| 617 |
+
fi
|
| 618 |
+
if grep -Fq "$marker" "$dump_path"; then
|
| 619 |
+
return
|
| 620 |
+
fi
|
| 621 |
+
fi
|
| 622 |
+
sleep 1
|
| 623 |
+
done
|
| 624 |
+
|
| 625 |
+
echo "Timed out waiting for scene '${scene}' to expose '${marker}' in the Android UI tree." >&2
|
| 626 |
+
return 1
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
sips_bin() {
|
| 630 |
+
if [[ -n "${SIPS:-}" ]]; then
|
| 631 |
+
printf '%s\n' "$SIPS"
|
| 632 |
+
return
|
| 633 |
+
fi
|
| 634 |
+
if command -v sips >/dev/null 2>&1; then
|
| 635 |
+
command -v sips
|
| 636 |
+
return
|
| 637 |
+
fi
|
| 638 |
+
echo "sips not found. Android release screenshots require macOS or an explicit SIPS executable." >&2
|
| 639 |
+
return 127
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
normalize_capture_for_play() {
|
| 643 |
+
local input_path="$1"
|
| 644 |
+
local output_path="$2"
|
| 645 |
+
local sips
|
| 646 |
+
local description
|
| 647 |
+
|
| 648 |
+
sips="$(sips_bin)"
|
| 649 |
+
"$sips" -s format jpeg -s formatOptions best "$input_path" --out "$output_path" >/dev/null
|
| 650 |
+
rm -f "$input_path"
|
| 651 |
+
|
| 652 |
+
description="$(file "$output_path")"
|
| 653 |
+
if [[ "$description" != *"${SCREENSHOT_SIZE/x/x}"* || "$description" != *"JPEG image data"* ]]; then
|
| 654 |
+
echo "Invalid Google Play screenshot output: ${description}" >&2
|
| 655 |
+
return 1
|
| 656 |
+
fi
|
| 657 |
+
}
|
| 658 |
+
|
| 659 |
+
write_artifact_manifest() {
|
| 660 |
+
local serial="$1"
|
| 661 |
+
local avd_name
|
| 662 |
+
local git_sha
|
| 663 |
+
local checksum_command
|
| 664 |
+
|
| 665 |
+
avd_name="$(running_avd_name "$ADB_BIN" "$serial")"
|
| 666 |
+
git_sha="$(git -C "$ROOT_DIR" rev-parse HEAD)"
|
| 667 |
+
if command -v shasum >/dev/null 2>&1; then
|
| 668 |
+
checksum_command=(shasum -a 256)
|
| 669 |
+
else
|
| 670 |
+
checksum_command=(sha256sum)
|
| 671 |
+
fi
|
| 672 |
+
|
| 673 |
+
{
|
| 674 |
+
printf 'git_sha=%s\n' "$git_sha"
|
| 675 |
+
printf 'form_factor=%s\n' "$FORM_FACTOR"
|
| 676 |
+
printf 'output_type=%s\n' "$OUTPUT_TYPE"
|
| 677 |
+
printf 'device=%s\n' "$serial"
|
| 678 |
+
printf 'avd=%s\n' "${avd_name:-unknown}"
|
| 679 |
+
printf 'locale=%s\n' "$LOCALE"
|
| 680 |
+
printf 'size=%s\n' "$SCREENSHOT_SIZE"
|
| 681 |
+
printf 'density=%s\n' "$SCREENSHOT_DENSITY"
|
| 682 |
+
printf 'format=high-quality JPEG\n'
|
| 683 |
+
printf 'scenes=%s\n' "${SCENES[*]}"
|
| 684 |
+
printf 'screenshots:\n'
|
| 685 |
+
"${checksum_command[@]}" "$ARTIFACT_DIR"/screenshots/*.jpg
|
| 686 |
+
} >"$ARTIFACT_DIR/manifest.txt"
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
OUTPUT_DIR="${ANDROID_DIR}/fastlane/metadata/android/${LOCALE}/images/${OUTPUT_TYPE}"
|
| 690 |
+
ADB_SERIAL=""
|
| 691 |
+
ADB_DISPLAY="${DEVICE:-<auto>}"
|
| 692 |
+
|
| 693 |
+
echo "Android screenshot output: ${OUTPUT_DIR}"
|
| 694 |
+
echo "Android screenshot artifacts: ${ARTIFACT_DIR}"
|
| 695 |
+
echo "Android screenshot form factor: ${FORM_FACTOR}"
|
| 696 |
+
echo "Android screenshot size: ${SCREENSHOT_SIZE}"
|
| 697 |
+
echo "Scenes: ${SCENES[*]}"
|
| 698 |
+
echo "ADB device: ${ADB_DISPLAY}"
|
| 699 |
+
echo "Screenshot AVD: ${AVD}"
|
| 700 |
+
echo "Screenshot device profile: ${SCREENSHOT_DEVICE_PROFILE}"
|
| 701 |
+
echo "Screenshot system image: ${SCREENSHOT_SYSTEM_IMAGE}"
|
| 702 |
+
|
| 703 |
+
if [[ "$DRY_RUN" == "1" ]]; then
|
| 704 |
+
echo "Dry run complete. No build, install, or capture commands were executed."
|
| 705 |
+
exit 0
|
| 706 |
+
fi
|
| 707 |
+
|
| 708 |
+
ADB_BIN="$(adb_bin)"
|
| 709 |
+
resolve_device "$ADB_BIN"
|
| 710 |
+
require_emulator_device "$ADB_BIN" "$ADB_SERIAL"
|
| 711 |
+
stabilize_device_for_screenshots "$ADB_BIN" "$ADB_SERIAL"
|
| 712 |
+
configure_screenshot_display "$ADB_BIN" "$ADB_SERIAL"
|
| 713 |
+
mkdir -p "$OUTPUT_DIR"
|
| 714 |
+
rm -f "$OUTPUT_DIR"/*.png "$OUTPUT_DIR"/*.jpg "$OUTPUT_DIR"/*.jpeg
|
| 715 |
+
rm -rf "$ARTIFACT_DIR"
|
| 716 |
+
mkdir -p "$ARTIFACT_DIR/screenshots" "$ARTIFACT_DIR/ui-dumps" "$ARTIFACT_DIR/activity-start"
|
| 717 |
+
|
| 718 |
+
if [[ "$SKIP_INSTALL" != "1" ]]; then
|
| 719 |
+
if [[ "$SKIP_BUILD" != "1" ]]; then
|
| 720 |
+
(
|
| 721 |
+
cd "$ANDROID_DIR"
|
| 722 |
+
./gradlew "$GRADLE_ASSEMBLE_TASK"
|
| 723 |
+
)
|
| 724 |
+
fi
|
| 725 |
+
APK_PATH="$(latest_debug_apk)"
|
| 726 |
+
if [[ -z "$APK_PATH" ]]; then
|
| 727 |
+
echo "No existing ${FORM_FACTOR} debug APK found. Run without --skip-build first." >&2
|
| 728 |
+
exit 1
|
| 729 |
+
fi
|
| 730 |
+
"$ADB_BIN" -s "$ADB_SERIAL" install -r "$APK_PATH" >/dev/null
|
| 731 |
+
elif [[ "$SKIP_BUILD" != "1" ]]; then
|
| 732 |
+
(
|
| 733 |
+
cd "$ANDROID_DIR"
|
| 734 |
+
./gradlew "$GRADLE_ASSEMBLE_TASK"
|
| 735 |
+
)
|
| 736 |
+
fi
|
| 737 |
+
|
| 738 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell pm clear "$APP_PACKAGE" >/dev/null
|
| 739 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell pm grant "$APP_PACKAGE" android.permission.RECORD_AUDIO >/dev/null
|
| 740 |
+
"$ADB_BIN" -s "$ADB_SERIAL" logcat -c >/dev/null 2>&1 || true
|
| 741 |
+
|
| 742 |
+
for scene in "${SCENES[@]}"; do
|
| 743 |
+
output_path="${OUTPUT_DIR}/openclaw-${scene}.jpg"
|
| 744 |
+
raw_path="${OUTPUT_DIR}/openclaw-${scene}.raw.png"
|
| 745 |
+
artifact_path="${ARTIFACT_DIR}/screenshots/openclaw-${scene}.jpg"
|
| 746 |
+
ui_dump_path="${ARTIFACT_DIR}/ui-dumps/openclaw-${scene}.xml"
|
| 747 |
+
activity_start_path="${ARTIFACT_DIR}/activity-start/openclaw-${scene}.txt"
|
| 748 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell am force-stop "$APP_PACKAGE" >/dev/null
|
| 749 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell input keyevent 224 >/dev/null 2>&1 || true
|
| 750 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell wm dismiss-keyguard >/dev/null 2>&1 || true
|
| 751 |
+
"$ADB_BIN" -s "$ADB_SERIAL" shell am start -W \
|
| 752 |
+
-n "$ACTIVITY_COMPONENT" \
|
| 753 |
+
--ez openclaw.screenshotMode true \
|
| 754 |
+
--es openclaw.screenshotScene "$scene" >"$activity_start_path"
|
| 755 |
+
wait_for_scene_ready "$ADB_BIN" "$ADB_SERIAL" "$scene" "$ui_dump_path"
|
| 756 |
+
sleep "${ANDROID_SCREENSHOT_SETTLE_SECONDS:-0.5}"
|
| 757 |
+
"$ADB_BIN" -s "$ADB_SERIAL" exec-out screencap -p >"$raw_path"
|
| 758 |
+
normalize_capture_for_play "$raw_path" "$output_path"
|
| 759 |
+
cp "$output_path" "$artifact_path"
|
| 760 |
+
echo "Captured ${output_path}"
|
| 761 |
+
done
|
| 762 |
+
|
| 763 |
+
"$ADB_BIN" -s "$ADB_SERIAL" logcat -d >"$ARTIFACT_DIR/logcat.txt" 2>&1 || true
|
| 764 |
+
if [[ -n "$EMULATOR_LOG" && -f "$EMULATOR_LOG" ]]; then
|
| 765 |
+
cp "$EMULATOR_LOG" "$ARTIFACT_DIR/emulator.log"
|
| 766 |
+
fi
|
| 767 |
+
write_artifact_manifest "$ADB_SERIAL"
|
| 768 |
+
|
| 769 |
+
echo "Android screenshots written to ${OUTPUT_DIR}"
|
| 770 |
+
echo "Android screenshot artifacts written to ${ARTIFACT_DIR}"
|
scripts/android-sips-linux.sh
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/bash
|
| 2 |
+
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
fail() {
|
| 5 |
+
printf 'android-sips-linux: %s\n' "$1" >&2
|
| 6 |
+
exit "${2:-1}"
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
if [[ $# -ne 9 ||
|
| 10 |
+
"$1" != "-s" || "$2" != "format" || "$3" != "jpeg" ||
|
| 11 |
+
"$4" != "-s" || "$5" != "formatOptions" || "$6" != "best" ||
|
| 12 |
+
"$8" != "--out" ]]; then
|
| 13 |
+
fail "unsupported arguments" 2
|
| 14 |
+
fi
|
| 15 |
+
|
| 16 |
+
input_path="$7"
|
| 17 |
+
output_path="$9"
|
| 18 |
+
convert_bin="/usr/bin/convert"
|
| 19 |
+
identify_bin="/usr/bin/identify"
|
| 20 |
+
|
| 21 |
+
[[ "$input_path" == /* && "$output_path" == /* ]] ||
|
| 22 |
+
fail "input and output paths must be absolute" 2
|
| 23 |
+
[[ "$input_path" != "$output_path" ]] || fail "input and output paths must differ" 2
|
| 24 |
+
[[ -f "$input_path" && ! -L "$input_path" ]] || fail "input is not a regular file"
|
| 25 |
+
[[ ! -e "$output_path" && ! -L "$output_path" ]] || fail "output already exists"
|
| 26 |
+
[[ -d "${output_path%/*}" ]] || fail "output directory does not exist"
|
| 27 |
+
|
| 28 |
+
[[ -x "$convert_bin" && -x "$identify_bin" ]] ||
|
| 29 |
+
fail "fixed ImageMagick executables are unavailable" 2
|
| 30 |
+
|
| 31 |
+
if ! input_dimensions="$("$identify_bin" -ping -format '%w %h' "$input_path" 2>/dev/null)"; then
|
| 32 |
+
fail "input is not a readable image"
|
| 33 |
+
fi
|
| 34 |
+
read -r input_width input_height extra <<<"$input_dimensions"
|
| 35 |
+
[[ "$input_width" =~ ^[1-9][0-9]*$ && "$input_height" =~ ^[1-9][0-9]*$ && -z "${extra:-}" ]] ||
|
| 36 |
+
fail "input dimensions are invalid"
|
| 37 |
+
|
| 38 |
+
umask 077
|
| 39 |
+
temporary_dir="$(mktemp -d "${output_path}.tmp.XXXXXX")"
|
| 40 |
+
temporary_output="$temporary_dir/output.jpg"
|
| 41 |
+
cleanup() {
|
| 42 |
+
rm -rf "$temporary_dir"
|
| 43 |
+
}
|
| 44 |
+
trap cleanup EXIT
|
| 45 |
+
|
| 46 |
+
if ! "$convert_bin" "$input_path" \
|
| 47 |
+
-colorspace sRGB \
|
| 48 |
+
-background white \
|
| 49 |
+
-alpha remove \
|
| 50 |
+
-alpha off \
|
| 51 |
+
-type TrueColor \
|
| 52 |
+
-quality 95 \
|
| 53 |
+
"jpeg:$temporary_output" >/dev/null 2>&1; then
|
| 54 |
+
fail "ImageMagick conversion failed"
|
| 55 |
+
fi
|
| 56 |
+
|
| 57 |
+
if ! output_description="$(
|
| 58 |
+
"$identify_bin" +ping -format '%m|%w|%h|%[colorspace]|%[type]|%[channels]|%Q' \
|
| 59 |
+
"$temporary_output" 2>/dev/null
|
| 60 |
+
)"; then
|
| 61 |
+
fail "converted output is not a readable image"
|
| 62 |
+
fi
|
| 63 |
+
IFS='|' read -r output_format output_width output_height output_colorspace \
|
| 64 |
+
output_type output_channels output_quality <<<"$output_description"
|
| 65 |
+
output_colorspace="$(printf '%s' "$output_colorspace" | tr '[:upper:]' '[:lower:]')"
|
| 66 |
+
output_channels="$(printf '%s' "$output_channels" | tr '[:upper:]' '[:lower:]')"
|
| 67 |
+
|
| 68 |
+
[[ "$output_format" == "JPEG" ]] || fail "converted output is not JPEG"
|
| 69 |
+
[[ "$output_width" == "$input_width" && "$output_height" == "$input_height" ]] ||
|
| 70 |
+
fail "converted output dimensions changed"
|
| 71 |
+
[[ "$output_colorspace" == "srgb" ]] || fail "converted output is not sRGB"
|
| 72 |
+
[[ "$output_type" == "TrueColor" ]] || fail "converted output is not true color"
|
| 73 |
+
[[ "$output_channels" != *a* ]] || fail "converted output retained an alpha channel"
|
| 74 |
+
[[ "$output_quality" =~ ^[0-9]+$ && "$output_quality" -ge 90 ]] ||
|
| 75 |
+
fail "converted output quality is too low"
|
| 76 |
+
|
| 77 |
+
mv "$temporary_output" "$output_path"
|
scripts/apple-app-i18n.ts
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
| 4 |
+
import { NATIVE_I18N_LOCALES } from "./native-i18n-locales.ts";
|
| 5 |
+
|
| 6 |
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
| 7 |
+
const ROOT = path.resolve(HERE, "..");
|
| 8 |
+
const REQUIRED_LOCALES = ["en", ...NATIVE_I18N_LOCALES];
|
| 9 |
+
const FORMAT_RE = /%(?:%|(?:\d+\$)?(?:lld|ld|[@a-z]))/giu;
|
| 10 |
+
const INFLECTED_COUNT_INTERPOLATION_RE = /\\\([A-Za-z_][A-Za-z0-9_]*\)/gu;
|
| 11 |
+
const INFLECTED_COUNT_INTERPOLATION_EXACT_RE = /^\\\([A-Za-z_][A-Za-z0-9_]*\)$/u;
|
| 12 |
+
const INFLECTED_COUNT_SEGMENT_RE =
|
| 13 |
+
/\^\[[^\]]*\\\([A-Za-z_][A-Za-z0-9_]*\)[^\]]*\]\(inflect: true\)/gu;
|
| 14 |
+
const INFLECTED_COUNT_MARKER = "](inflect: true)";
|
| 15 |
+
const IOS_CATALOG_PATH = "apps/ios/Resources/Localizable.xcstrings";
|
| 16 |
+
const MACOS_CATALOG_PATH = "apps/macos/Sources/OpenClaw/Resources/Localizable.xcstrings";
|
| 17 |
+
const MACOS_INFO_PLIST_PATH = "apps/macos/Sources/OpenClaw/Resources/Info.plist";
|
| 18 |
+
const NATIVE_SOURCE_PATH = "apps/.i18n/native-source.json";
|
| 19 |
+
const NATIVE_TRANSLATIONS_DIR = "apps/.i18n/native";
|
| 20 |
+
const SHARED_CHAT_UI_SOURCE_PREFIX = "apps/shared/OpenClawKit/Sources/OpenClawChatUI/";
|
| 21 |
+
const SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE =
|
| 22 |
+
"apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayDiscoveryStatusText.swift";
|
| 23 |
+
const IOS_SOURCE_PREFIXES = [
|
| 24 |
+
"apps/ios/",
|
| 25 |
+
SHARED_CHAT_UI_SOURCE_PREFIX,
|
| 26 |
+
"apps/shared/OpenClawKit/Sources/OpenClawKit/",
|
| 27 |
+
] as const;
|
| 28 |
+
const IOS_CATALOG_EXCLUSIONS = new Set([
|
| 29 |
+
// Product names and preview-only single-character fixtures are intentionally verbatim.
|
| 30 |
+
"OpenClaw",
|
| 31 |
+
"z",
|
| 32 |
+
]);
|
| 33 |
+
const MACOS_SOURCE_PREFIXES = [
|
| 34 |
+
"apps/macos/Sources/OpenClaw/",
|
| 35 |
+
SHARED_CHAT_UI_SOURCE_PREFIX,
|
| 36 |
+
SHARED_GATEWAY_DISCOVERY_STATUS_SOURCE,
|
| 37 |
+
] as const;
|
| 38 |
+
const MACOS_CATALOG_EXCLUSIONS = new Set([
|
| 39 |
+
// Product names are intentionally verbatim.
|
| 40 |
+
"OpenClaw",
|
| 41 |
+
]);
|
| 42 |
+
const IOS_INFO_PLIST_TARGETS = [
|
| 43 |
+
{
|
| 44 |
+
outputRoot: "apps/ios/Sources",
|
| 45 |
+
sourcePath: "apps/ios/Sources/Info.plist",
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
outputRoot: "apps/ios/WatchApp",
|
| 49 |
+
sourcePath: "apps/ios/WatchApp/Info.plist",
|
| 50 |
+
},
|
| 51 |
+
{
|
| 52 |
+
outputRoot: "apps/ios/ShareExtension",
|
| 53 |
+
sourcePath: "apps/ios/ShareExtension/Info.plist",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
outputRoot: "apps/ios/ActivityWidget",
|
| 57 |
+
sourcePath: "apps/ios/ActivityWidget/Info.plist",
|
| 58 |
+
},
|
| 59 |
+
] as const;
|
| 60 |
+
const INFO_PLIST_LOCALIZABLE_KEYS = new Set(["NSScreenCaptureDescription"]);
|
| 61 |
+
const AMBIGUOUS_RUNTIME_INTERPOLATIONS = [
|
| 62 |
+
{
|
| 63 |
+
label: "interpolated localized resource",
|
| 64 |
+
pattern:
|
| 65 |
+
/(?:\bString\s*\(\s*localized:|\bAttributedString\s*\(\s*localized:|\bLocalizedString(?:Key|Resource)\s*\(|(?:\b[A-Za-z_]\w*)?\.localized(?:Format)?\s*\()\s*"((?:\\.|[^"\\])*)"/gu,
|
| 66 |
+
allowsInflection: true,
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
label: "interpolated multiline localized resource",
|
| 70 |
+
pattern:
|
| 71 |
+
/\b(?:String\s*\(\s*localized:|AttributedString\s*\(\s*localized:|LocalizedString(?:Key|Resource)\s*\()\s*"""([\s\S]*?)"""/gu,
|
| 72 |
+
allowsInflection: true,
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
label: "interpolated SwiftUI text literal",
|
| 76 |
+
pattern:
|
| 77 |
+
/\b(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"((?:\\.|[^"\\])*)"/gu,
|
| 78 |
+
allowsInflection: false,
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
label: "interpolated multiline SwiftUI text literal",
|
| 82 |
+
pattern:
|
| 83 |
+
/\b(?:Text|Label|Button|TextField|SecureField|Picker|Section|LabeledContent|Toggle|Menu|ShareLink|Link|TextEditor|ProgressView|Gauge|DisclosureGroup|ControlGroup|DatePicker|Stepper)\s*\(\s*"""([\s\S]*?)"""/gu,
|
| 84 |
+
allowsInflection: false,
|
| 85 |
+
},
|
| 86 |
+
{
|
| 87 |
+
label: "interpolated multiline SwiftUI modifier literal",
|
| 88 |
+
pattern:
|
| 89 |
+
/\.(?:accessibilityLabel|accessibilityHint|alert|confirmationDialog|help|navigationTitle)\s*\(\s*"""([\s\S]*?)"""/gu,
|
| 90 |
+
allowsInflection: false,
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
label: "interpolated SwiftUI modifier literal",
|
| 94 |
+
pattern:
|
| 95 |
+
/\.(?:accessibilityLabel|accessibilityHint|alert|confirmationDialog|help|navigationTitle)\s*\(\s*"((?:\\.|[^"\\])*)"/gu,
|
| 96 |
+
allowsInflection: false,
|
| 97 |
+
},
|
| 98 |
+
{
|
| 99 |
+
label: "interpolated accessibility model literal",
|
| 100 |
+
pattern: /\baccessibilityLabel\s*:\s*"((?:\\.|[^"\\])*)"/gu,
|
| 101 |
+
allowsInflection: false,
|
| 102 |
+
},
|
| 103 |
+
] as const;
|
| 104 |
+
|
| 105 |
+
export function findAmbiguousRuntimeInterpolations(source: string): string[] {
|
| 106 |
+
const violations: string[] = [];
|
| 107 |
+
for (const { label, pattern, allowsInflection } of AMBIGUOUS_RUNTIME_INTERPOLATIONS) {
|
| 108 |
+
pattern.lastIndex = 0;
|
| 109 |
+
for (const match of source.matchAll(pattern)) {
|
| 110 |
+
const literal = match[1] ?? "";
|
| 111 |
+
if (!literal.includes("\\(") || (allowsInflection && isInflectedCountSource(literal))) {
|
| 112 |
+
continue;
|
| 113 |
+
}
|
| 114 |
+
violations.push(label);
|
| 115 |
+
break;
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
return violations;
|
| 119 |
+
}
|
| 120 |
+
const APPLE_LOCALE_DIRECTORIES: Record<string, string> = {
|
| 121 |
+
"ja-JP": "ja",
|
| 122 |
+
"zh-CN": "zh-Hans",
|
| 123 |
+
"zh-TW": "zh-Hant",
|
| 124 |
+
};
|
| 125 |
+
const LOCALIZED_WRAPPER_CONTRACTS: Record<string, readonly string[]> = {
|
| 126 |
+
"apps/macos/Sources/OpenClaw/DeviceSettingsPanels.swift": [
|
| 127 |
+
'String(localized: "Quick Chat shortcut")',
|
| 128 |
+
'String(localized: "Microphone Test")',
|
| 129 |
+
'Button("Done")',
|
| 130 |
+
],
|
| 131 |
+
"apps/macos/Sources/OpenClaw/DeviceMicrophonePanel.swift": [
|
| 132 |
+
'String(localized: "Stopped")',
|
| 133 |
+
'String(localized: "Timeout: no trigger heard")',
|
| 134 |
+
"Text(verbatim: meterError)",
|
| 135 |
+
],
|
| 136 |
+
"apps/ios/Sources/Design/OpenClawProComponents.swift": [
|
| 137 |
+
"enum OpenClawTextValue: ExpressibleByStringLiteral",
|
| 138 |
+
"struct OpenClawNoticeBanner: View {\n let icon: String\n let title: OpenClawTextValue\n let message: OpenClawTextValue",
|
| 139 |
+
"struct OpenClawAdaptiveHeaderRow<Leading: View, Accessory: View>: View {\n let title: OpenClawTextValue\n let subtitle: OpenClawTextValue?",
|
| 140 |
+
"struct OpenClawStatusBadge: View {\n @Environment(\\.colorScheme) private var colorScheme\n let label: OpenClawTextValue",
|
| 141 |
+
"struct ProStatusRow: View {\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue",
|
| 142 |
+
],
|
| 143 |
+
"apps/ios/Sources/Design/SettingsProTabSupport.swift": [
|
| 144 |
+
"struct SettingsDetailRow: View {\n let label: LocalizedStringKey\n let value: OpenClawTextValue",
|
| 145 |
+
"init(_ label: LocalizedStringKey, value: OpenClawTextValue)",
|
| 146 |
+
"self.value.text",
|
| 147 |
+
"struct SettingsApprovalItem: Identifiable {\n let id: String\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue\n let priority: OpenClawTextValue",
|
| 148 |
+
"self.item.title.text",
|
| 149 |
+
"self.item.detail.text",
|
| 150 |
+
"self.item.priority.text",
|
| 151 |
+
],
|
| 152 |
+
"apps/ios/Sources/Design/SettingsProTabActions.swift": [
|
| 153 |
+
"func detailStatusCard(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue,\n value: OpenClawTextValue",
|
| 154 |
+
"func diagnosticCheckRow(\n icon: String,\n title: OpenClawTextValue,\n detail: OpenClawTextValue,\n value: OpenClawTextValue",
|
| 155 |
+
"pendingApproval.commandPreview.map(OpenClawTextValue.verbatim)",
|
| 156 |
+
'format: String(localized: "Agent: %@")',
|
| 157 |
+
],
|
| 158 |
+
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
|
| 159 |
+
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: LocalizedStringKey",
|
| 160 |
+
"func aboutLinkRow(\n title: LocalizedStringKey",
|
| 161 |
+
"func gatewaySecureField(\n _ placeholder: LocalizedStringKey",
|
| 162 |
+
"func settingsToggle(\n _ title: LocalizedStringKey",
|
| 163 |
+
".accessibilityLabel(Text(placeholder))",
|
| 164 |
+
".accessibilityLabel(Text(title))",
|
| 165 |
+
],
|
| 166 |
+
"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift": [
|
| 167 |
+
'String(localized: "Secure connection is required for this host.")',
|
| 168 |
+
'String(localized: "Use only on a trusted private network.")',
|
| 169 |
+
],
|
| 170 |
+
"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift": [
|
| 171 |
+
"private struct ExecApprovalPromptMetadataRow: View {\n let label: LocalizedStringKey",
|
| 172 |
+
'localized: "about ^[\\(minutes) minute](inflect: true)"',
|
| 173 |
+
'localized: "about ^[\\(hours) hour](inflect: true)"',
|
| 174 |
+
],
|
| 175 |
+
"apps/ios/Sources/Voice/TalkGatewayPermissionState.swift": [
|
| 176 |
+
'String(format: String(localized: "Missing %@"), scope)',
|
| 177 |
+
'String(localized: "Requesting approval")',
|
| 178 |
+
],
|
| 179 |
+
"apps/ios/Sources/Voice/TalkModeManager.swift": [
|
| 180 |
+
'format: String(localized: "Speech error: %@")',
|
| 181 |
+
'format: String(localized: "Speak failed: %@")',
|
| 182 |
+
'self.setStatus(String(localized: "Listening (PTT)"), phase: .listening)',
|
| 183 |
+
],
|
| 184 |
+
"apps/ios/Sources/Voice/VoiceWakeManager.swift": [
|
| 185 |
+
'format: String(localized: "Recognizer error: %@")',
|
| 186 |
+
'self.statusText = String(localized: "Triggered")',
|
| 187 |
+
],
|
| 188 |
+
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
|
| 189 |
+
"Text(verbatim: self.item.title)",
|
| 190 |
+
"Text(verbatim: self.item.trailing)",
|
| 191 |
+
"Text(verbatim: self.item.detail)",
|
| 192 |
+
"struct CommandEmptyStateRow: View {\n let icon: String\n let title: OpenClawTextValue\n let detail: OpenClawTextValue",
|
| 193 |
+
"private func actionButton(\n _ title: OpenClawTextValue",
|
| 194 |
+
"self.actionButton(.verbatim(category)",
|
| 195 |
+
],
|
| 196 |
+
"apps/ios/Sources/Gateway/GatewayQuickSetupSheet.swift": [
|
| 197 |
+
"fullRowToggle(_ title: LocalizedStringKey",
|
| 198 |
+
"private struct GatewayQuickSetupStatusRow: View {\n let title: LocalizedStringKey\n let value: String",
|
| 199 |
+
"Text(LocalizedStringKey(availability.actionTitle))",
|
| 200 |
+
"Text(verbatim: self.message)",
|
| 201 |
+
],
|
| 202 |
+
"apps/ios/Sources/Gateway/GatewayProblemView.swift": [
|
| 203 |
+
"title: .verbatim(self.problem.localizedTitle)",
|
| 204 |
+
"message: .verbatim(self.problem.localizedMessage)",
|
| 205 |
+
"Text(verbatim: primaryActionTitle)",
|
| 206 |
+
],
|
| 207 |
+
"apps/ios/Sources/LiveActivity/LiveActivityManager.swift": [
|
| 208 |
+
'String(localized: "Connecting...")',
|
| 209 |
+
"status: .disconnected",
|
| 210 |
+
],
|
| 211 |
+
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
|
| 212 |
+
"enum WatchTextValue {\n case localized(LocalizedStringResource)",
|
| 213 |
+
"private struct WatchFaceHeader: View {\n let section: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
|
| 214 |
+
"private struct WatchHeroCard: View {\n let label: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
|
| 215 |
+
"private struct WatchStackCard: View {\n let label: WatchTextValue\n let title: WatchTextValue\n let subtitle: WatchTextValue",
|
| 216 |
+
"private struct WatchDecisionButton: View {\n let title: LocalizedStringKey",
|
| 217 |
+
'format: String(localized: "Expires in %@")',
|
| 218 |
+
'String(localized: "Pending review")',
|
| 219 |
+
'String(localized: "Review command below")',
|
| 220 |
+
"title: .verbatim(record.approval.commandPreview",
|
| 221 |
+
],
|
| 222 |
+
"apps/ios/WatchApp/Sources/WatchDirectNode.swift": [
|
| 223 |
+
'private(set) var statusText = String(\n localized: "Use iPhone Settings to enable direct connection.")',
|
| 224 |
+
'format: String(localized: "Direct connection failed: %@")',
|
| 225 |
+
],
|
| 226 |
+
"apps/ios/WatchApp/Sources/WatchInboxStore.swift": [
|
| 227 |
+
"WatchAppCommandStatus(command: command, code: .sending)",
|
| 228 |
+
"WatchAppCommandStatus(command: command, code: .sent)",
|
| 229 |
+
"WatchAppCommandStatus(command: command, code: .queued)",
|
| 230 |
+
],
|
| 231 |
+
};
|
| 232 |
+
const RAW_LOCALIZATION_BYPASSES: Record<string, readonly string[]> = {
|
| 233 |
+
"apps/ios/Sources/Design/SettingsProTabSections.swift": [
|
| 234 |
+
"func settingsListRow(\n icon: String,\n iconColor: Color,\n title: String",
|
| 235 |
+
"func aboutLinkRow(title: String",
|
| 236 |
+
"func gatewayActionButton(\n title: String",
|
| 237 |
+
"func gatewaySecureField(_ placeholder: String",
|
| 238 |
+
"func settingsToggle(\n _ title: String",
|
| 239 |
+
".accessibilityLabel(title)",
|
| 240 |
+
],
|
| 241 |
+
"apps/ios/Sources/Gateway/GatewayConnectionController+Capabilities.swift": [
|
| 242 |
+
'helperText: "Secure connection is required for this host."',
|
| 243 |
+
'helperText: "Use only on a trusted private network."',
|
| 244 |
+
],
|
| 245 |
+
"apps/ios/Sources/Gateway/ExecApprovalPromptDialog.swift": [
|
| 246 |
+
"private struct ExecApprovalPromptMetadataRow: View {\n let label: String",
|
| 247 |
+
'return "under a minute"',
|
| 248 |
+
'return "about 1 minute"',
|
| 249 |
+
],
|
| 250 |
+
"apps/ios/Sources/Voice/TalkModeManager.swift": [
|
| 251 |
+
'self.statusText = "',
|
| 252 |
+
'streamingOwner.terminalStatus = "',
|
| 253 |
+
],
|
| 254 |
+
"apps/ios/Sources/Voice/VoiceWakeManager.swift": ['self.statusText = "'],
|
| 255 |
+
"apps/ios/Sources/Design/SettingsProTabSupport.swift": [
|
| 256 |
+
"struct SettingsDetailRow: View {\n let label: LocalizedStringKey\n let value: String",
|
| 257 |
+
"init(_ label: LocalizedStringKey, value: String)",
|
| 258 |
+
"Text(self.value)",
|
| 259 |
+
"Text(self.item.title)",
|
| 260 |
+
"Text(self.item.detail)",
|
| 261 |
+
"Text(self.item.priority)",
|
| 262 |
+
],
|
| 263 |
+
"apps/ios/Sources/Design/CommandCenterSupport.swift": [
|
| 264 |
+
"Text(self.item.title)",
|
| 265 |
+
"Text(self.item.trailing)",
|
| 266 |
+
"Text(self.item.detail)",
|
| 267 |
+
"struct CommandEmptyStateRow: View {\n let icon: String\n let title: String",
|
| 268 |
+
"private func actionButton(\n _ title: String",
|
| 269 |
+
],
|
| 270 |
+
"apps/ios/Sources/Design/SettingsProTabActions.swift": [
|
| 271 |
+
"func detailStatusCard(\n icon: String,\n title: String",
|
| 272 |
+
"func diagnosticCheckRow(\n icon: String,\n title: String",
|
| 273 |
+
],
|
| 274 |
+
"apps/ios/WatchApp/Sources/WatchInboxView.swift": [
|
| 275 |
+
'parts.append("Expires in \\(expiresText)")',
|
| 276 |
+
'return "Expires in <1m"',
|
| 277 |
+
'return "Expires in \\(deltaSeconds / 60)m"',
|
| 278 |
+
'return "Pending review"',
|
| 279 |
+
'return "Review command below"',
|
| 280 |
+
'return "Stop speaking"',
|
| 281 |
+
'return "Cancel voice turn"',
|
| 282 |
+
'return "Start voice turn"',
|
| 283 |
+
],
|
| 284 |
+
"apps/ios/WatchApp/Sources/WatchDirectNode.swift": [
|
| 285 |
+
'private(set) var statusText = "',
|
| 286 |
+
'self.statusText = "',
|
| 287 |
+
],
|
| 288 |
+
"apps/ios/WatchApp/Sources/WatchInboxStore.swift": [
|
| 289 |
+
'self.appSnapshotStatusText = "',
|
| 290 |
+
'self.appCommandStatusText = "',
|
| 291 |
+
'self.execApprovals[index].statusText = "',
|
| 292 |
+
'self.replyStatusText = "',
|
| 293 |
+
],
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
type StringUnit = {
|
| 297 |
+
state?: string;
|
| 298 |
+
value?: string;
|
| 299 |
+
};
|
| 300 |
+
|
| 301 |
+
type CatalogLocalization = Record<string, unknown> & {
|
| 302 |
+
stringUnit?: StringUnit;
|
| 303 |
+
};
|
| 304 |
+
|
| 305 |
+
type CatalogEntry = Record<string, unknown> & {
|
| 306 |
+
localizations?: Record<string, CatalogLocalization>;
|
| 307 |
+
};
|
| 308 |
+
|
| 309 |
+
type Catalog = {
|
| 310 |
+
sourceLanguage?: string;
|
| 311 |
+
strings?: Record<string, CatalogEntry>;
|
| 312 |
+
version?: string;
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
type NativeSourceEntry = {
|
| 316 |
+
id: string;
|
| 317 |
+
source: string;
|
| 318 |
+
sites: Array<{ kind: string; path: string }>;
|
| 319 |
+
surface: string;
|
| 320 |
+
};
|
| 321 |
+
|
| 322 |
+
type NativeSourceArtifact = {
|
| 323 |
+
entries: NativeSourceEntry[];
|
| 324 |
+
version: number;
|
| 325 |
+
};
|
| 326 |
+
|
| 327 |
+
type NativeTranslationArtifact = {
|
| 328 |
+
locale: string;
|
| 329 |
+
translations: Record<string, string>;
|
| 330 |
+
version: number;
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
export type AppleTranslationContradiction = {
|
| 334 |
+
locale: string;
|
| 335 |
+
source: string;
|
| 336 |
+
translations: string[];
|
| 337 |
+
};
|
| 338 |
+
|
| 339 |
+
export type AppleCatalogBuild = {
|
| 340 |
+
catalog: Catalog;
|
| 341 |
+
contradictions: AppleTranslationContradiction[];
|
| 342 |
+
};
|
| 343 |
+
|
| 344 |
+
function formatTokens(value: string): string[] {
|
| 345 |
+
return [...value.matchAll(FORMAT_RE)].map((match) => match[0]).toSorted();
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function stringsLiteral(value: string): string {
|
| 349 |
+
return JSON.stringify(value);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
function compareCodeUnits(left: string, right: string): number {
|
| 353 |
+
return left < right ? -1 : left > right ? 1 : 0;
|
| 354 |
+
}
|
| 355 |
+
|
| 356 |
+
export function serializeAppleCatalog(catalog: Catalog): string {
|
| 357 |
+
const topLevelEntries = Object.entries(catalog);
|
| 358 |
+
const lines = ["{"];
|
| 359 |
+
|
| 360 |
+
for (const [topLevelIndex, [key, value]] of topLevelEntries.entries()) {
|
| 361 |
+
const comma = topLevelIndex === topLevelEntries.length - 1 ? "" : ",";
|
| 362 |
+
if (key !== "strings" || !value || typeof value !== "object" || Array.isArray(value)) {
|
| 363 |
+
lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
|
| 364 |
+
continue;
|
| 365 |
+
}
|
| 366 |
+
|
| 367 |
+
const entries = Object.entries(value);
|
| 368 |
+
lines.push(` ${JSON.stringify(key)}: {`);
|
| 369 |
+
for (const [entryIndex, [entryKey, entry]] of entries.entries()) {
|
| 370 |
+
const entryComma = entryIndex === entries.length - 1 ? "" : ",";
|
| 371 |
+
lines.push(` ${JSON.stringify(entryKey)}: ${JSON.stringify(entry)}${entryComma}`);
|
| 372 |
+
}
|
| 373 |
+
lines.push(` }${comma}`);
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
lines.push("}", "");
|
| 377 |
+
return lines.join("\n");
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function decodeXml(value: string): string {
|
| 381 |
+
return value
|
| 382 |
+
.replaceAll(""", '"')
|
| 383 |
+
.replaceAll("'", "'")
|
| 384 |
+
.replaceAll("<", "<")
|
| 385 |
+
.replaceAll(">", ">")
|
| 386 |
+
.replaceAll("&", "&");
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
function parseInfoPlistStrings(source: string): Array<{ key: string; source: string }> {
|
| 390 |
+
return [...source.matchAll(/<key>([^<]+)<\/key>\s*<string>([\s\S]*?)<\/string>/gu)]
|
| 391 |
+
.map((match) => ({
|
| 392 |
+
key: decodeXml(match[1] ?? ""),
|
| 393 |
+
source: decodeXml(match[2] ?? ""),
|
| 394 |
+
}))
|
| 395 |
+
.filter(
|
| 396 |
+
(entry) =>
|
| 397 |
+
entry.key.endsWith("UsageDescription") || INFO_PLIST_LOCALIZABLE_KEYS.has(entry.key),
|
| 398 |
+
);
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
type InfoPlistTranslation = {
|
| 402 |
+
source?: string;
|
| 403 |
+
value: string;
|
| 404 |
+
};
|
| 405 |
+
|
| 406 |
+
function parseStringsFile(source: string): Map<string, InfoPlistTranslation> {
|
| 407 |
+
const values = new Map<string, InfoPlistTranslation>();
|
| 408 |
+
for (const match of source.matchAll(
|
| 409 |
+
/(?:^\/\* OpenClaw source: ("(?:\\.|[^"\\])*") \*\/\n)?^\s*("(?:\\.|[^"\\])*")\s*=\s*("(?:\\.|[^"\\])*");/gmu,
|
| 410 |
+
)) {
|
| 411 |
+
values.set(JSON.parse(match[2] ?? '""') as string, {
|
| 412 |
+
source: match[1] ? (JSON.parse(match[1]) as string) : undefined,
|
| 413 |
+
value: JSON.parse(match[3] ?? '""') as string,
|
| 414 |
+
});
|
| 415 |
+
}
|
| 416 |
+
return values;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
export function selectInfoPlistTranslation(
|
| 420 |
+
source: string,
|
| 421 |
+
candidates: readonly string[],
|
| 422 |
+
existing?: InfoPlistTranslation,
|
| 423 |
+
): string {
|
| 424 |
+
const translatedCandidates = candidates.filter(
|
| 425 |
+
(candidate) => candidate.trim() && candidate.trim() !== source.trim(),
|
| 426 |
+
);
|
| 427 |
+
if (translatedCandidates.length > 0) {
|
| 428 |
+
return chooseTranslation(source, translatedCandidates);
|
| 429 |
+
}
|
| 430 |
+
return existing?.source === source && existing.value.trim() ? existing.value : source;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
export function infoPlistTranslationCandidates(
|
| 434 |
+
artifact: NativeTranslationArtifact | undefined,
|
| 435 |
+
sourceId: string,
|
| 436 |
+
_source: string,
|
| 437 |
+
): string[] {
|
| 438 |
+
const translated = artifact?.translations[sourceId];
|
| 439 |
+
return typeof translated === "string" ? [translated] : [];
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
function infoPlistSourceIds(nativeSource: NativeSourceArtifact): Map<string, string> {
|
| 443 |
+
return new Map(
|
| 444 |
+
nativeSource.entries.flatMap((entry) =>
|
| 445 |
+
entry.sites
|
| 446 |
+
.filter((site) => site.kind === "plist-string")
|
| 447 |
+
.map((site) => [[site.path, entry.source].join("\u0000"), entry.id] as const),
|
| 448 |
+
),
|
| 449 |
+
);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function renderInfoPlistStrings(
|
| 453 |
+
sourcePath: string,
|
| 454 |
+
sourceEntries: ReadonlyArray<{ key: string; source: string }>,
|
| 455 |
+
sourceIds: ReadonlyMap<string, string>,
|
| 456 |
+
artifact: NativeTranslationArtifact | undefined,
|
| 457 |
+
existing: ReadonlyMap<string, InfoPlistTranslation> = new Map(),
|
| 458 |
+
): string {
|
| 459 |
+
const lines = sourceEntries.map(({ key, source }) => {
|
| 460 |
+
const sourceId = sourceIds.get([sourcePath, source].join("\u0000"));
|
| 461 |
+
if (!sourceId) {
|
| 462 |
+
throw new Error(`missing native InfoPlist source id for ${sourcePath}:${key}`);
|
| 463 |
+
}
|
| 464 |
+
const candidates = infoPlistTranslationCandidates(artifact, sourceId, source);
|
| 465 |
+
const value = selectInfoPlistTranslation(source, candidates, existing.get(key));
|
| 466 |
+
return [
|
| 467 |
+
`/* OpenClaw source: ${stringsLiteral(source)} */`,
|
| 468 |
+
`${stringsLiteral(key)} = ${stringsLiteral(value)};`,
|
| 469 |
+
].join("\n");
|
| 470 |
+
});
|
| 471 |
+
return `${lines.join("\n")}\n`;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
async function readOptionalFile(filePath: string): Promise<string | null> {
|
| 475 |
+
try {
|
| 476 |
+
return await readFile(filePath, "utf8");
|
| 477 |
+
} catch (error) {
|
| 478 |
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
| 479 |
+
return null;
|
| 480 |
+
}
|
| 481 |
+
throw error;
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
function isAppleCatalogKind(kind: string): boolean {
|
| 486 |
+
return kind === "conditional-branch" || kind.startsWith("ui-");
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
function isAppleCatalogEntry(
|
| 490 |
+
entry: NativeSourceEntry,
|
| 491 |
+
sourcePrefixes: readonly string[],
|
| 492 |
+
exclusions: ReadonlySet<string>,
|
| 493 |
+
): boolean {
|
| 494 |
+
return (
|
| 495 |
+
entry.surface === "apple" &&
|
| 496 |
+
entry.sites.some(
|
| 497 |
+
(site) =>
|
| 498 |
+
sourcePrefixes.some((prefix) => site.path.startsWith(prefix)) &&
|
| 499 |
+
isAppleCatalogKind(site.kind),
|
| 500 |
+
) &&
|
| 501 |
+
(!entry.source.includes("\\(") || isInflectedCountSource(entry.source)) &&
|
| 502 |
+
!exclusions.has(entry.source)
|
| 503 |
+
);
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
function isInflectedCountSource(value: string): boolean {
|
| 507 |
+
if (!value.includes(INFLECTED_COUNT_MARKER)) {
|
| 508 |
+
return false;
|
| 509 |
+
}
|
| 510 |
+
const interpolations = value.match(/\\\([^)]*\)/gu) ?? [];
|
| 511 |
+
const segments = [...value.matchAll(INFLECTED_COUNT_SEGMENT_RE)];
|
| 512 |
+
return (
|
| 513 |
+
segments.length > 0 &&
|
| 514 |
+
interpolations.length === segments.length &&
|
| 515 |
+
interpolations.every((interpolation) =>
|
| 516 |
+
INFLECTED_COUNT_INTERPOLATION_EXACT_RE.test(interpolation),
|
| 517 |
+
) &&
|
| 518 |
+
segments.every(
|
| 519 |
+
([segment]) => (segment.match(INFLECTED_COUNT_INTERPOLATION_RE) ?? []).length === 1,
|
| 520 |
+
)
|
| 521 |
+
);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
function appleCatalogValue(value: string): string {
|
| 525 |
+
if (!isInflectedCountSource(value)) {
|
| 526 |
+
return value;
|
| 527 |
+
}
|
| 528 |
+
return value.replace(INFLECTED_COUNT_SEGMENT_RE, (segment) =>
|
| 529 |
+
segment.replace(INFLECTED_COUNT_INTERPOLATION_RE, "%lld"),
|
| 530 |
+
);
|
| 531 |
+
}
|
| 532 |
+
|
| 533 |
+
function chooseTranslation(source: string, translations: readonly string[]): string {
|
| 534 |
+
// Apple catalogs key by source, so duplicate native contexts must converge.
|
| 535 |
+
// Preserve shipped values first; otherwise choose deterministically and report every conflict.
|
| 536 |
+
const counts = new Map<string, number>();
|
| 537 |
+
for (const translation of translations) {
|
| 538 |
+
counts.set(translation, (counts.get(translation) ?? 0) + 1);
|
| 539 |
+
}
|
| 540 |
+
return (
|
| 541 |
+
[...counts].toSorted(([leftValue, leftCount], [rightValue, rightCount]) => {
|
| 542 |
+
const sourcePenalty = Number(leftValue === source) - Number(rightValue === source);
|
| 543 |
+
return sourcePenalty || rightCount - leftCount || compareCodeUnits(leftValue, rightValue);
|
| 544 |
+
})[0]?.[0] ?? source
|
| 545 |
+
);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function buildAppleCatalog(
|
| 549 |
+
existingCatalog: Catalog,
|
| 550 |
+
nativeSource: NativeSourceArtifact,
|
| 551 |
+
translations: readonly NativeTranslationArtifact[],
|
| 552 |
+
includesEntry: (entry: NativeSourceEntry) => boolean,
|
| 553 |
+
): AppleCatalogBuild {
|
| 554 |
+
const catalogEntries = nativeSource.entries
|
| 555 |
+
.filter(includesEntry)
|
| 556 |
+
.map((entry) => [entry, appleCatalogValue(entry.source)] as const);
|
| 557 |
+
const sources = [...new Set(catalogEntries.map(([, source]) => source))].toSorted(
|
| 558 |
+
compareCodeUnits,
|
| 559 |
+
);
|
| 560 |
+
const catalogIds = new Set(catalogEntries.map(([entry]) => entry.id));
|
| 561 |
+
const existingStrings = existingCatalog.strings ?? {};
|
| 562 |
+
const nativeEntryById = new Map(nativeSource.entries.map((entry) => [entry.id, entry]));
|
| 563 |
+
const translationsByLocale = new Map(
|
| 564 |
+
translations.map((artifact) => {
|
| 565 |
+
const bySource = new Map<string, string[]>();
|
| 566 |
+
for (const [id, translated] of Object.entries(artifact.translations)) {
|
| 567 |
+
const entry = nativeEntryById.get(id);
|
| 568 |
+
if (!entry || !catalogIds.has(id)) {
|
| 569 |
+
continue;
|
| 570 |
+
}
|
| 571 |
+
const source = appleCatalogValue(entry.source);
|
| 572 |
+
const values = bySource.get(source) ?? [];
|
| 573 |
+
values.push(appleCatalogValue(translated));
|
| 574 |
+
bySource.set(source, values);
|
| 575 |
+
}
|
| 576 |
+
return [artifact.locale, bySource] as const;
|
| 577 |
+
}),
|
| 578 |
+
);
|
| 579 |
+
const contradictions: AppleTranslationContradiction[] = [];
|
| 580 |
+
const strings: Record<string, CatalogEntry> = {};
|
| 581 |
+
|
| 582 |
+
for (const source of sources) {
|
| 583 |
+
const existing = existingStrings[source];
|
| 584 |
+
const localizations: NonNullable<CatalogEntry["localizations"]> = {};
|
| 585 |
+
for (const locale of REQUIRED_LOCALES) {
|
| 586 |
+
const candidates = translationsByLocale.get(locale)?.get(source) ?? [];
|
| 587 |
+
const distinct = [...new Set(candidates)].toSorted(compareCodeUnits);
|
| 588 |
+
if (distinct.length > 1) {
|
| 589 |
+
contradictions.push({ locale, source, translations: distinct });
|
| 590 |
+
}
|
| 591 |
+
const existingUnit = existing?.localizations?.[locale]?.stringUnit;
|
| 592 |
+
if (existingUnit?.value && existingUnit.state === "translated") {
|
| 593 |
+
localizations[locale] = { stringUnit: { ...existingUnit } };
|
| 594 |
+
continue;
|
| 595 |
+
}
|
| 596 |
+
if (locale === "en") {
|
| 597 |
+
localizations.en = {
|
| 598 |
+
stringUnit: {
|
| 599 |
+
state: "translated",
|
| 600 |
+
value: source,
|
| 601 |
+
},
|
| 602 |
+
};
|
| 603 |
+
continue;
|
| 604 |
+
}
|
| 605 |
+
const value = chooseTranslation(source, candidates);
|
| 606 |
+
localizations[locale] = {
|
| 607 |
+
stringUnit: {
|
| 608 |
+
state: value === source ? "new" : "translated",
|
| 609 |
+
value,
|
| 610 |
+
},
|
| 611 |
+
};
|
| 612 |
+
}
|
| 613 |
+
strings[source] = { localizations };
|
| 614 |
+
}
|
| 615 |
+
|
| 616 |
+
return {
|
| 617 |
+
catalog: {
|
| 618 |
+
sourceLanguage: "en",
|
| 619 |
+
strings,
|
| 620 |
+
version: "1.0",
|
| 621 |
+
},
|
| 622 |
+
contradictions: contradictions.toSorted(
|
| 623 |
+
(left, right) =>
|
| 624 |
+
compareCodeUnits(left.source, right.source) || compareCodeUnits(left.locale, right.locale),
|
| 625 |
+
),
|
| 626 |
+
};
|
| 627 |
+
}
|
| 628 |
+
|
| 629 |
+
export function buildIosCatalog(
|
| 630 |
+
existingCatalog: Catalog,
|
| 631 |
+
nativeSource: NativeSourceArtifact,
|
| 632 |
+
translations: readonly NativeTranslationArtifact[],
|
| 633 |
+
): AppleCatalogBuild {
|
| 634 |
+
return buildAppleCatalog(existingCatalog, nativeSource, translations, (entry) =>
|
| 635 |
+
isAppleCatalogEntry(entry, IOS_SOURCE_PREFIXES, IOS_CATALOG_EXCLUSIONS),
|
| 636 |
+
);
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
export function buildMacosCatalog(
|
| 640 |
+
existingCatalog: Catalog,
|
| 641 |
+
nativeSource: NativeSourceArtifact,
|
| 642 |
+
translations: readonly NativeTranslationArtifact[],
|
| 643 |
+
): AppleCatalogBuild {
|
| 644 |
+
return buildAppleCatalog(existingCatalog, nativeSource, translations, (entry) =>
|
| 645 |
+
isAppleCatalogEntry(entry, MACOS_SOURCE_PREFIXES, MACOS_CATALOG_EXCLUSIONS),
|
| 646 |
+
);
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
async function listSwiftFiles(directory: string): Promise<string[]> {
|
| 650 |
+
const entries = await readdir(directory, { withFileTypes: true });
|
| 651 |
+
const nested = await Promise.all(
|
| 652 |
+
entries.map(async (entry) => {
|
| 653 |
+
if (
|
| 654 |
+
entry.isDirectory() &&
|
| 655 |
+
/^(?:\.build|build|DerivedData|Previews?|Tests?|UITests?)$/u.test(entry.name)
|
| 656 |
+
) {
|
| 657 |
+
return [];
|
| 658 |
+
}
|
| 659 |
+
const entryPath = path.join(directory, entry.name);
|
| 660 |
+
if (entry.isDirectory()) {
|
| 661 |
+
return listSwiftFiles(entryPath);
|
| 662 |
+
}
|
| 663 |
+
return entry.isFile() && entry.name.endsWith(".swift") ? [entryPath] : [];
|
| 664 |
+
}),
|
| 665 |
+
);
|
| 666 |
+
return nested.flat();
|
| 667 |
+
}
|
| 668 |
+
|
| 669 |
+
async function validateRuntimeInterpolationPaths(): Promise<void> {
|
| 670 |
+
const roots = [...new Set([...IOS_SOURCE_PREFIXES, ...MACOS_SOURCE_PREFIXES])];
|
| 671 |
+
const files = new Set(
|
| 672 |
+
(
|
| 673 |
+
await Promise.all(
|
| 674 |
+
roots.map((prefix) => {
|
| 675 |
+
const sourcePath = path.join(ROOT, prefix);
|
| 676 |
+
return prefix.endsWith(".swift")
|
| 677 |
+
? Promise.resolve([sourcePath])
|
| 678 |
+
: listSwiftFiles(sourcePath);
|
| 679 |
+
}),
|
| 680 |
+
)
|
| 681 |
+
).flat(),
|
| 682 |
+
);
|
| 683 |
+
const violations: string[] = [];
|
| 684 |
+
for (const file of [...files].toSorted()) {
|
| 685 |
+
const source = await readFile(file, "utf8");
|
| 686 |
+
for (const label of findAmbiguousRuntimeInterpolations(source)) {
|
| 687 |
+
violations.push(`${path.relative(ROOT, file)}: ${label}`);
|
| 688 |
+
}
|
| 689 |
+
}
|
| 690 |
+
if (violations.length) {
|
| 691 |
+
throw new Error(
|
| 692 |
+
`Apple i18n runtime interpolation bypasses generated catalog coverage:\n${violations.join("\n")}`,
|
| 693 |
+
);
|
| 694 |
+
}
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
async function readNativeTranslations(): Promise<NativeTranslationArtifact[]> {
|
| 698 |
+
const expectedFiles = NATIVE_I18N_LOCALES.map((locale) => `${locale}.json`).toSorted();
|
| 699 |
+
const actualFiles = (
|
| 700 |
+
await readdir(path.join(ROOT, NATIVE_TRANSLATIONS_DIR), {
|
| 701 |
+
withFileTypes: true,
|
| 702 |
+
})
|
| 703 |
+
)
|
| 704 |
+
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
| 705 |
+
.map((entry) => entry.name)
|
| 706 |
+
.toSorted();
|
| 707 |
+
if (JSON.stringify(actualFiles) !== JSON.stringify(expectedFiles)) {
|
| 708 |
+
throw new Error(
|
| 709 |
+
`Apple/native locale parity drift: expected ${JSON.stringify(expectedFiles)}, got ${JSON.stringify(actualFiles)}`,
|
| 710 |
+
);
|
| 711 |
+
}
|
| 712 |
+
return Promise.all(
|
| 713 |
+
NATIVE_I18N_LOCALES.map(async (locale) => {
|
| 714 |
+
const artifact = JSON.parse(
|
| 715 |
+
await readFile(path.join(ROOT, NATIVE_TRANSLATIONS_DIR, `${locale}.json`), "utf8"),
|
| 716 |
+
) as NativeTranslationArtifact;
|
| 717 |
+
if (artifact.locale !== locale) {
|
| 718 |
+
throw new Error(
|
| 719 |
+
`native Apple translation artifact ${locale} declares locale ${artifact.locale}`,
|
| 720 |
+
);
|
| 721 |
+
}
|
| 722 |
+
return artifact;
|
| 723 |
+
}),
|
| 724 |
+
);
|
| 725 |
+
}
|
| 726 |
+
|
| 727 |
+
async function readAppleCatalogBuild(
|
| 728 |
+
catalogPath: string,
|
| 729 |
+
buildCatalog: typeof buildIosCatalog,
|
| 730 |
+
): Promise<AppleCatalogBuild> {
|
| 731 |
+
const existingCatalog = JSON.parse(
|
| 732 |
+
await readFile(path.join(ROOT, catalogPath), "utf8"),
|
| 733 |
+
) as Catalog;
|
| 734 |
+
const nativeSource = JSON.parse(
|
| 735 |
+
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
|
| 736 |
+
) as NativeSourceArtifact;
|
| 737 |
+
const translations = await readNativeTranslations();
|
| 738 |
+
return buildCatalog(existingCatalog, nativeSource, translations);
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
function validateCatalog(pathName: string, catalog: Catalog): number {
|
| 742 |
+
if (catalog.sourceLanguage !== "en" || catalog.version !== "1.0" || !catalog.strings) {
|
| 743 |
+
throw new Error(`invalid Apple string catalog: ${pathName}`);
|
| 744 |
+
}
|
| 745 |
+
let checked = 0;
|
| 746 |
+
for (const [key, entry] of Object.entries(catalog.strings)) {
|
| 747 |
+
const sourceTokens = formatTokens(key);
|
| 748 |
+
for (const locale of REQUIRED_LOCALES) {
|
| 749 |
+
const unit = entry.localizations?.[locale]?.stringUnit;
|
| 750 |
+
const value = unit?.value?.trim();
|
| 751 |
+
if (!value || (locale === "en" && unit?.state !== "translated")) {
|
| 752 |
+
throw new Error(
|
| 753 |
+
`Apple catalog ${pathName} is missing ${locale} for ${JSON.stringify(key)}`,
|
| 754 |
+
);
|
| 755 |
+
}
|
| 756 |
+
if (locale !== "en" && unit?.state !== "translated" && unit?.state !== "new") {
|
| 757 |
+
throw new Error(
|
| 758 |
+
`Apple catalog ${pathName} has untranslated ${locale} copy for ${JSON.stringify(key)}`,
|
| 759 |
+
);
|
| 760 |
+
}
|
| 761 |
+
if (formatTokens(value).join("\u0000") !== sourceTokens.join("\u0000")) {
|
| 762 |
+
throw new Error(
|
| 763 |
+
`Apple catalog ${pathName} has placeholder drift in ${locale} for ${JSON.stringify(key)}`,
|
| 764 |
+
);
|
| 765 |
+
}
|
| 766 |
+
}
|
| 767 |
+
checked += 1;
|
| 768 |
+
}
|
| 769 |
+
return checked;
|
| 770 |
+
}
|
| 771 |
+
|
| 772 |
+
async function syncIosInfoPlist(write: boolean): Promise<number> {
|
| 773 |
+
const translations = await readNativeTranslations();
|
| 774 |
+
const nativeSource = JSON.parse(
|
| 775 |
+
await readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8"),
|
| 776 |
+
) as NativeSourceArtifact;
|
| 777 |
+
const sourceIds = infoPlistSourceIds(nativeSource);
|
| 778 |
+
let checked = 0;
|
| 779 |
+
for (const target of IOS_INFO_PLIST_TARGETS) {
|
| 780 |
+
const sourceEntries = parseInfoPlistStrings(
|
| 781 |
+
await readFile(path.join(ROOT, target.sourcePath), "utf8"),
|
| 782 |
+
);
|
| 783 |
+
for (const locale of NATIVE_I18N_LOCALES) {
|
| 784 |
+
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
|
| 785 |
+
const outputPath = path.join(
|
| 786 |
+
ROOT,
|
| 787 |
+
target.outputRoot,
|
| 788 |
+
`${localeDir}.lproj`,
|
| 789 |
+
"InfoPlist.strings",
|
| 790 |
+
);
|
| 791 |
+
const existingSource = await readOptionalFile(outputPath);
|
| 792 |
+
const existing = parseStringsFile(existingSource ?? "");
|
| 793 |
+
const artifact = translations.find((candidate) => candidate.locale === locale);
|
| 794 |
+
const expected = renderInfoPlistStrings(
|
| 795 |
+
target.sourcePath,
|
| 796 |
+
sourceEntries,
|
| 797 |
+
sourceIds,
|
| 798 |
+
artifact,
|
| 799 |
+
existing,
|
| 800 |
+
);
|
| 801 |
+
if (existingSource !== expected) {
|
| 802 |
+
if (!write) {
|
| 803 |
+
throw new Error(
|
| 804 |
+
`Apple InfoPlist localization ${path.relative(ROOT, outputPath)} is stale; run apple-app-i18n.ts sync-ios --write`,
|
| 805 |
+
);
|
| 806 |
+
}
|
| 807 |
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
| 808 |
+
await writeFile(outputPath, expected, "utf8");
|
| 809 |
+
}
|
| 810 |
+
checked += 1;
|
| 811 |
+
}
|
| 812 |
+
}
|
| 813 |
+
return checked;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
export async function syncIosCatalog(write: boolean): Promise<AppleCatalogBuild> {
|
| 817 |
+
const build = await readAppleCatalogBuild(IOS_CATALOG_PATH, buildIosCatalog);
|
| 818 |
+
const catalogPath = path.join(ROOT, IOS_CATALOG_PATH);
|
| 819 |
+
const expected = serializeAppleCatalog(build.catalog);
|
| 820 |
+
const actual = await readFile(catalogPath, "utf8");
|
| 821 |
+
if (actual !== expected) {
|
| 822 |
+
if (!write) {
|
| 823 |
+
throw new Error(
|
| 824 |
+
`Apple catalog ${IOS_CATALOG_PATH} is stale; run apple-app-i18n.ts sync-ios --write`,
|
| 825 |
+
);
|
| 826 |
+
}
|
| 827 |
+
await writeFile(catalogPath, expected, "utf8");
|
| 828 |
+
}
|
| 829 |
+
return build;
|
| 830 |
+
}
|
| 831 |
+
|
| 832 |
+
export async function syncMacosCatalog(write: boolean): Promise<AppleCatalogBuild> {
|
| 833 |
+
const build = await readAppleCatalogBuild(MACOS_CATALOG_PATH, buildMacosCatalog);
|
| 834 |
+
const catalogPath = path.join(ROOT, MACOS_CATALOG_PATH);
|
| 835 |
+
const expected = serializeAppleCatalog(build.catalog);
|
| 836 |
+
const actual = await readFile(catalogPath, "utf8");
|
| 837 |
+
if (actual !== expected) {
|
| 838 |
+
if (!write) {
|
| 839 |
+
assertMacosCatalogCurrent(actual, build);
|
| 840 |
+
return build;
|
| 841 |
+
}
|
| 842 |
+
await writeFile(catalogPath, expected, "utf8");
|
| 843 |
+
}
|
| 844 |
+
return build;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
export function assertMacosCatalogCurrent(actual: string, build: AppleCatalogBuild): void {
|
| 848 |
+
if (actual !== serializeAppleCatalog(build.catalog)) {
|
| 849 |
+
throw new Error(
|
| 850 |
+
`Apple catalog ${MACOS_CATALOG_PATH} is stale; run native-app-i18n.ts sync --write`,
|
| 851 |
+
);
|
| 852 |
+
}
|
| 853 |
+
}
|
| 854 |
+
|
| 855 |
+
/**
|
| 856 |
+
* Regenerates every Apple derived artifact (app catalogs and InfoPlist strings).
|
| 857 |
+
* Shared by this CLI and native-app-i18n's sync so the inventory can never be
|
| 858 |
+
* rewritten without its derived catalogs.
|
| 859 |
+
*/
|
| 860 |
+
export async function syncAppleAppI18n(): Promise<{
|
| 861 |
+
build: AppleCatalogBuild;
|
| 862 |
+
infoPlistFiles: number;
|
| 863 |
+
macosBuild: AppleCatalogBuild;
|
| 864 |
+
}> {
|
| 865 |
+
const [build, macosBuild] = await Promise.all([syncIosCatalog(true), syncMacosCatalog(true)]);
|
| 866 |
+
const infoPlistFiles = await syncIosInfoPlist(true);
|
| 867 |
+
return { build, infoPlistFiles, macosBuild };
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
export async function verifyAppleAppI18n() {
|
| 871 |
+
await validateRuntimeInterpolationPaths();
|
| 872 |
+
for (const [sourcePath, contracts] of Object.entries(LOCALIZED_WRAPPER_CONTRACTS)) {
|
| 873 |
+
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
|
| 874 |
+
const missing = contracts.filter((contract) => !source.includes(contract));
|
| 875 |
+
if (missing.length) {
|
| 876 |
+
throw new Error(
|
| 877 |
+
`Apple i18n wrapper ${sourcePath} bypasses localized string lookup: ${missing.join(", ")}`,
|
| 878 |
+
);
|
| 879 |
+
}
|
| 880 |
+
}
|
| 881 |
+
for (const [sourcePath, bypasses] of Object.entries(RAW_LOCALIZATION_BYPASSES)) {
|
| 882 |
+
const source = await readFile(path.join(ROOT, sourcePath), "utf8");
|
| 883 |
+
const present = bypasses.filter((bypass) => source.includes(bypass));
|
| 884 |
+
if (present.length) {
|
| 885 |
+
throw new Error(
|
| 886 |
+
`Apple i18n runtime path ${sourcePath} contains raw app-owned strings: ${present.join(", ")}`,
|
| 887 |
+
);
|
| 888 |
+
}
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
const macosBuild = await readAppleCatalogBuild(MACOS_CATALOG_PATH, buildMacosCatalog);
|
| 892 |
+
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
|
| 893 |
+
|
| 894 |
+
process.stdout.write(`apple-app-i18n: sourceMacosKeys=${macosKeys}\n`);
|
| 895 |
+
}
|
| 896 |
+
|
| 897 |
+
export async function checkAppleAppI18n() {
|
| 898 |
+
await verifyAppleAppI18n();
|
| 899 |
+
const [iosBuild, macosBuild] = await Promise.all([
|
| 900 |
+
syncIosCatalog(false),
|
| 901 |
+
syncMacosCatalog(false),
|
| 902 |
+
]);
|
| 903 |
+
const iosKeys = validateCatalog(IOS_CATALOG_PATH, iosBuild.catalog);
|
| 904 |
+
const macosKeys = validateCatalog(MACOS_CATALOG_PATH, macosBuild.catalog);
|
| 905 |
+
const infoPlistFiles = await syncIosInfoPlist(false);
|
| 906 |
+
|
| 907 |
+
process.stdout.write(
|
| 908 |
+
[
|
| 909 |
+
`apple-app-i18n: iosKeys=${iosKeys}`,
|
| 910 |
+
`macosKeys=${macosKeys}`,
|
| 911 |
+
`infoPlistFiles=${infoPlistFiles}`,
|
| 912 |
+
`translationContradictions=${iosBuild.contradictions.length}`,
|
| 913 |
+
`macosTranslationContradictions=${macosBuild.contradictions.length}`,
|
| 914 |
+
`locales=${NATIVE_I18N_LOCALES.join(",")}`,
|
| 915 |
+
"\n",
|
| 916 |
+
].join(" "),
|
| 917 |
+
);
|
| 918 |
+
}
|
| 919 |
+
|
| 920 |
+
export async function compileMacosLocalizations(outputDir: string) {
|
| 921 |
+
// Source PRs intentionally leave generated Apple catalogs for the serialized
|
| 922 |
+
// post-merge refresh. Package from the derived catalog so source changes
|
| 923 |
+
// cannot ship stale localization coverage before that refresh lands.
|
| 924 |
+
await verifyAppleAppI18n();
|
| 925 |
+
const catalog = (await readAppleCatalogBuild(MACOS_CATALOG_PATH, buildMacosCatalog)).catalog;
|
| 926 |
+
if (!catalog.strings) {
|
| 927 |
+
throw new Error(`invalid Apple string catalog: ${MACOS_CATALOG_PATH}`);
|
| 928 |
+
}
|
| 929 |
+
const [nativeSource, translations, infoPlistSource] = await Promise.all([
|
| 930 |
+
readFile(path.join(ROOT, NATIVE_SOURCE_PATH), "utf8").then(
|
| 931 |
+
(source) => JSON.parse(source) as NativeSourceArtifact,
|
| 932 |
+
),
|
| 933 |
+
readNativeTranslations(),
|
| 934 |
+
readFile(path.join(ROOT, MACOS_INFO_PLIST_PATH), "utf8"),
|
| 935 |
+
]);
|
| 936 |
+
const sourceIds = infoPlistSourceIds(nativeSource);
|
| 937 |
+
const infoPlistEntries = parseInfoPlistStrings(infoPlistSource);
|
| 938 |
+
|
| 939 |
+
for (const locale of REQUIRED_LOCALES) {
|
| 940 |
+
const localeDir = APPLE_LOCALE_DIRECTORIES[locale] ?? locale;
|
| 941 |
+
const lprojDir = path.join(outputDir, `${localeDir}.lproj`);
|
| 942 |
+
const lines = Object.entries(catalog.strings)
|
| 943 |
+
.toSorted(([left], [right]) => compareCodeUnits(left, right))
|
| 944 |
+
.map(([key, entry]) => {
|
| 945 |
+
const value = entry.localizations?.[locale]?.stringUnit?.value;
|
| 946 |
+
if (!value) {
|
| 947 |
+
throw new Error(
|
| 948 |
+
`Apple catalog ${MACOS_CATALOG_PATH} is missing ${locale} for ${JSON.stringify(key)}`,
|
| 949 |
+
);
|
| 950 |
+
}
|
| 951 |
+
return `${stringsLiteral(key)} = ${stringsLiteral(value)};`;
|
| 952 |
+
});
|
| 953 |
+
await mkdir(lprojDir, { recursive: true });
|
| 954 |
+
await writeFile(path.join(lprojDir, "Localizable.strings"), `${lines.join("\n")}\n`, "utf8");
|
| 955 |
+
if (locale !== "en") {
|
| 956 |
+
const artifact = translations.find((candidate) => candidate.locale === locale);
|
| 957 |
+
const infoPlistStrings = renderInfoPlistStrings(
|
| 958 |
+
MACOS_INFO_PLIST_PATH,
|
| 959 |
+
infoPlistEntries,
|
| 960 |
+
sourceIds,
|
| 961 |
+
artifact,
|
| 962 |
+
);
|
| 963 |
+
await writeFile(path.join(lprojDir, "InfoPlist.strings"), infoPlistStrings, "utf8");
|
| 964 |
+
}
|
| 965 |
+
}
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
| 969 |
+
const [command, flag, value] = process.argv.slice(2);
|
| 970 |
+
if (command === "check") {
|
| 971 |
+
await checkAppleAppI18n();
|
| 972 |
+
} else if (command === "sync-ios" && flag === "--write") {
|
| 973 |
+
const { build, infoPlistFiles, macosBuild } = await syncAppleAppI18n();
|
| 974 |
+
process.stdout.write(
|
| 975 |
+
`apple-app-i18n: synced Apple catalogs and ${infoPlistFiles} InfoPlist files; contradictions=${build.contradictions.length + macosBuild.contradictions.length}\n`,
|
| 976 |
+
);
|
| 977 |
+
} else if (command === "compile-macos" && flag === "--output" && value) {
|
| 978 |
+
await compileMacosLocalizations(path.resolve(value));
|
| 979 |
+
} else {
|
| 980 |
+
throw new Error(
|
| 981 |
+
"usage: node --import tsx scripts/apple-app-i18n.ts check|sync-ios --write|compile-macos --output <dir>",
|
| 982 |
+
);
|
| 983 |
+
}
|
| 984 |
+
}
|
scripts/audit-seams.mts
ADDED
|
@@ -0,0 +1,1049 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Audits repo ownership seams, optional plugin leaks, and nearby test coverage signals.
|
| 3 |
+
|
| 4 |
+
import { promises as fs } from "node:fs";
|
| 5 |
+
import path from "node:path";
|
| 6 |
+
import { fileURLToPath } from "node:url";
|
| 7 |
+
import ts from "typescript";
|
| 8 |
+
import {
|
| 9 |
+
BUNDLED_PLUGIN_PATH_PREFIX,
|
| 10 |
+
BUNDLED_PLUGIN_ROOT_DIR,
|
| 11 |
+
} from "./lib/bundled-plugin-paths.mjs";
|
| 12 |
+
import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
|
| 13 |
+
import { optionalBundledClusterSet } from "./lib/optional-bundled-clusters.mjs";
|
| 14 |
+
import { escapeRegExp } from "./lib/regexp.mjs";
|
| 15 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 16 |
+
import { toLine } from "./lib/ts-guard-utils.mts";
|
| 17 |
+
|
| 18 |
+
type ImportEntry = {
|
| 19 |
+
family: string;
|
| 20 |
+
file: string;
|
| 21 |
+
kind: string;
|
| 22 |
+
line: number;
|
| 23 |
+
resolvedPath: string;
|
| 24 |
+
specifier: string;
|
| 25 |
+
};
|
| 26 |
+
type OptionalClusterImportEntry = Omit<ImportEntry, "family"> & { cluster: string };
|
| 27 |
+
type ModuleSpecifierVisit = {
|
| 28 |
+
kind: string;
|
| 29 |
+
specifierNode: ts.Node;
|
| 30 |
+
specifier: string;
|
| 31 |
+
};
|
| 32 |
+
const MATCH_QUALITY_RANK = {
|
| 33 |
+
"exact-stem": 0,
|
| 34 |
+
"path-nearby": 1,
|
| 35 |
+
"direct-import": 2,
|
| 36 |
+
"dir-token": 3,
|
| 37 |
+
} as const satisfies Record<string, number>;
|
| 38 |
+
type MatchQuality = keyof typeof MATCH_QUALITY_RANK;
|
| 39 |
+
type RelatedTestMatch = { file: string; matchQuality: MatchQuality };
|
| 40 |
+
type PackageJson = {
|
| 41 |
+
dependencies?: Record<string, string>;
|
| 42 |
+
optionalDependencies?: Record<string, string>;
|
| 43 |
+
devDependencies?: Record<string, string>;
|
| 44 |
+
name?: string;
|
| 45 |
+
private?: boolean;
|
| 46 |
+
openclaw?: { install?: { npmSpec?: string } };
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 50 |
+
const srcRoot = path.join(repoRoot, "src");
|
| 51 |
+
const extensionsRoot = path.join(repoRoot, BUNDLED_PLUGIN_ROOT_DIR);
|
| 52 |
+
const testRoot = path.join(repoRoot, "test");
|
| 53 |
+
const workspacePackagePaths = ["ui/package.json"];
|
| 54 |
+
const MAX_SCAN_BYTES = 2 * 1024 * 1024;
|
| 55 |
+
const compareStrings = (left: string, right: string) => left.localeCompare(right);
|
| 56 |
+
const HELP_TEXT = `Usage: node --import tsx scripts/audit-seams.mts [--help]
|
| 57 |
+
|
| 58 |
+
Audit repo seam inventory and emit JSON to stdout.
|
| 59 |
+
|
| 60 |
+
Sections:
|
| 61 |
+
duplicatedSeamFamilies Plugin SDK seam families imported from multiple production files
|
| 62 |
+
overlapFiles Production files that touch multiple seam families
|
| 63 |
+
optionalClusterStaticLeaks Optional extension/plugin clusters referenced from the static graph
|
| 64 |
+
missingPackages Workspace packages whose deps are not mirrored at the root
|
| 65 |
+
seamTestInventory High-signal seam candidates with nearby-test gap signals,
|
| 66 |
+
including cron orchestration seams for agent handoff,
|
| 67 |
+
outbound/media delivery, heartbeat/followup handoff,
|
| 68 |
+
and scheduler state crossings, plus subagent seams
|
| 69 |
+
for spawn/session handoff, announce delivery,
|
| 70 |
+
lifecycle registry, cleanup, and parent streaming
|
| 71 |
+
|
| 72 |
+
Notes:
|
| 73 |
+
- Output is JSON only.
|
| 74 |
+
- For clean redirected JSON through package scripts, prefer:
|
| 75 |
+
pnpm --silent audit:seams > seam-inventory.json
|
| 76 |
+
`;
|
| 77 |
+
|
| 78 |
+
async function collectWorkspacePackagePaths() {
|
| 79 |
+
const entries = await fs.readdir(extensionsRoot, { withFileTypes: true });
|
| 80 |
+
for (const entry of entries) {
|
| 81 |
+
if (entry.isDirectory()) {
|
| 82 |
+
workspacePackagePaths.push(path.join("extensions", entry.name, "package.json"));
|
| 83 |
+
}
|
| 84 |
+
}
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function normalizePath(filePath: string) {
|
| 88 |
+
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
async function readScannableText(filePath: string, maxBytes = MAX_SCAN_BYTES) {
|
| 92 |
+
const stat = await fs.stat(filePath);
|
| 93 |
+
if (stat.size <= maxBytes) {
|
| 94 |
+
return fs.readFile(filePath, "utf8");
|
| 95 |
+
}
|
| 96 |
+
const handle = await fs.open(filePath, "r");
|
| 97 |
+
try {
|
| 98 |
+
const buffer = Buffer.alloc(maxBytes);
|
| 99 |
+
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
|
| 100 |
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
| 101 |
+
} finally {
|
| 102 |
+
await handle.close();
|
| 103 |
+
}
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function redactNpmSpec(npmSpec: unknown) {
|
| 107 |
+
if (typeof npmSpec !== "string") {
|
| 108 |
+
return npmSpec ?? null;
|
| 109 |
+
}
|
| 110 |
+
return npmSpec
|
| 111 |
+
.replace(/(https?:\/\/)([^/\s:@]+):([^/\s@]+)@/gi, "$1***:***@")
|
| 112 |
+
.replace(/(https?:\/\/)([^/\s:@]+)@/gi, "$1***@");
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
function isCodeFile(fileName: string) {
|
| 116 |
+
return /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(fileName);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
function isTestLikePath(relativePath: string) {
|
| 120 |
+
return (
|
| 121 |
+
/(^|\/)(__tests__|fixtures|test-utils|test-fixtures)\//.test(relativePath) ||
|
| 122 |
+
/(?:^|\/)[^/]*(?:[.-](?:test|spec))(?:[.-][^/]+)?\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(
|
| 123 |
+
relativePath,
|
| 124 |
+
)
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
function isProductionLikeFile(relativePath: string) {
|
| 129 |
+
return !isTestLikePath(relativePath);
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
async function walkCodeFiles(rootDir: string) {
|
| 133 |
+
const out: string[] = [];
|
| 134 |
+
async function walk(dir: string): Promise<void> {
|
| 135 |
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
| 136 |
+
for (const entry of entries) {
|
| 137 |
+
if (entry.name === "dist" || entry.name === "node_modules") {
|
| 138 |
+
continue;
|
| 139 |
+
}
|
| 140 |
+
const fullPath = path.join(dir, entry.name);
|
| 141 |
+
if (entry.isDirectory()) {
|
| 142 |
+
await walk(fullPath);
|
| 143 |
+
continue;
|
| 144 |
+
}
|
| 145 |
+
if (!entry.isFile() || !isCodeFile(entry.name)) {
|
| 146 |
+
continue;
|
| 147 |
+
}
|
| 148 |
+
const relativePath = normalizePath(fullPath);
|
| 149 |
+
if (!isProductionLikeFile(relativePath)) {
|
| 150 |
+
continue;
|
| 151 |
+
}
|
| 152 |
+
out.push(fullPath);
|
| 153 |
+
}
|
| 154 |
+
}
|
| 155 |
+
await walk(rootDir);
|
| 156 |
+
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
async function walkAllCodeFiles(rootDir: string, options: { includeTests?: boolean } = {}) {
|
| 160 |
+
const out: string[] = [];
|
| 161 |
+
const includeTests = options.includeTests === true;
|
| 162 |
+
|
| 163 |
+
async function walk(dir: string): Promise<void> {
|
| 164 |
+
let entries;
|
| 165 |
+
try {
|
| 166 |
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
| 167 |
+
} catch {
|
| 168 |
+
return;
|
| 169 |
+
}
|
| 170 |
+
for (const entry of entries) {
|
| 171 |
+
if (entry.name === "dist" || entry.name === "node_modules") {
|
| 172 |
+
continue;
|
| 173 |
+
}
|
| 174 |
+
const fullPath = path.join(dir, entry.name);
|
| 175 |
+
if (entry.isDirectory()) {
|
| 176 |
+
await walk(fullPath);
|
| 177 |
+
continue;
|
| 178 |
+
}
|
| 179 |
+
if (!entry.isFile() || !isCodeFile(entry.name)) {
|
| 180 |
+
continue;
|
| 181 |
+
}
|
| 182 |
+
const relativePath = normalizePath(fullPath);
|
| 183 |
+
if (!includeTests && !isProductionLikeFile(relativePath)) {
|
| 184 |
+
continue;
|
| 185 |
+
}
|
| 186 |
+
out.push(fullPath);
|
| 187 |
+
}
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
await walk(rootDir);
|
| 191 |
+
return out.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
function resolveRelativeSpecifier(specifier: string, importerFile: string) {
|
| 195 |
+
if (!specifier.startsWith(".")) {
|
| 196 |
+
return null;
|
| 197 |
+
}
|
| 198 |
+
return normalizePath(path.resolve(path.dirname(importerFile), specifier));
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
function normalizePluginSdkFamily(resolvedPath: string) {
|
| 202 |
+
const relative = resolvedPath.replace(/^src\/plugin-sdk\//, "");
|
| 203 |
+
return relative.replace(/\.(m|c)?[jt]sx?$/, "");
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
function resolveOptionalClusterFromPath(resolvedPath: string) {
|
| 207 |
+
if (resolvedPath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)) {
|
| 208 |
+
const [, cluster] = resolvedPath.split("/");
|
| 209 |
+
return cluster && optionalBundledClusterSet.has(cluster) ? cluster : null;
|
| 210 |
+
}
|
| 211 |
+
if (resolvedPath.startsWith("src/plugin-sdk/")) {
|
| 212 |
+
const [cluster] = normalizePluginSdkFamily(resolvedPath).split("/");
|
| 213 |
+
return cluster && optionalBundledClusterSet.has(cluster) ? cluster : null;
|
| 214 |
+
}
|
| 215 |
+
return null;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
function compareImports(left: ImportEntry, right: ImportEntry) {
|
| 219 |
+
return (
|
| 220 |
+
left.family.localeCompare(right.family) ||
|
| 221 |
+
left.file.localeCompare(right.file) ||
|
| 222 |
+
left.line - right.line ||
|
| 223 |
+
left.kind.localeCompare(right.kind) ||
|
| 224 |
+
left.specifier.localeCompare(right.specifier)
|
| 225 |
+
);
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function collectPluginSdkImports(filePath: string, sourceFile: ts.SourceFile): ImportEntry[] {
|
| 229 |
+
const entries: ImportEntry[] = [];
|
| 230 |
+
|
| 231 |
+
function push(kind: string, specifierNode: ts.Node, specifier: string) {
|
| 232 |
+
const resolvedPath = resolveRelativeSpecifier(specifier, filePath);
|
| 233 |
+
if (!resolvedPath?.startsWith("src/plugin-sdk/")) {
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
entries.push({
|
| 237 |
+
family: normalizePluginSdkFamily(resolvedPath),
|
| 238 |
+
file: normalizePath(filePath),
|
| 239 |
+
kind,
|
| 240 |
+
line: toLine(sourceFile, specifierNode),
|
| 241 |
+
resolvedPath,
|
| 242 |
+
specifier,
|
| 243 |
+
});
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
const visit = ({ kind, specifierNode, specifier }: ModuleSpecifierVisit) =>
|
| 247 |
+
push(kind, specifierNode, specifier);
|
| 248 |
+
visitModuleSpecifiers(ts, sourceFile, visit);
|
| 249 |
+
return entries;
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
async function collectCorePluginSdkImports() {
|
| 253 |
+
const files = await walkCodeFiles(srcRoot);
|
| 254 |
+
const inventory: ImportEntry[] = [];
|
| 255 |
+
for (const filePath of files) {
|
| 256 |
+
if (normalizePath(filePath).startsWith("src/plugin-sdk/")) {
|
| 257 |
+
continue;
|
| 258 |
+
}
|
| 259 |
+
const source = await fs.readFile(filePath, "utf8");
|
| 260 |
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
| 261 |
+
inventory.push(...collectPluginSdkImports(filePath, sourceFile));
|
| 262 |
+
}
|
| 263 |
+
return inventory.toSorted(compareImports);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
function collectOptionalClusterStaticImports(
|
| 267 |
+
filePath: string,
|
| 268 |
+
sourceFile: ts.SourceFile,
|
| 269 |
+
): OptionalClusterImportEntry[] {
|
| 270 |
+
const entries: OptionalClusterImportEntry[] = [];
|
| 271 |
+
|
| 272 |
+
function push(kind: string, specifierNode: ts.Node, specifier: string) {
|
| 273 |
+
if (!specifier.startsWith(".")) {
|
| 274 |
+
return;
|
| 275 |
+
}
|
| 276 |
+
const resolvedPath = resolveRelativeSpecifier(specifier, filePath);
|
| 277 |
+
if (!resolvedPath) {
|
| 278 |
+
return;
|
| 279 |
+
}
|
| 280 |
+
const cluster = resolveOptionalClusterFromPath(resolvedPath);
|
| 281 |
+
if (!cluster) {
|
| 282 |
+
return;
|
| 283 |
+
}
|
| 284 |
+
entries.push({
|
| 285 |
+
cluster,
|
| 286 |
+
file: normalizePath(filePath),
|
| 287 |
+
kind,
|
| 288 |
+
line: toLine(sourceFile, specifierNode),
|
| 289 |
+
resolvedPath,
|
| 290 |
+
specifier,
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
const visit = ({ kind, specifierNode, specifier }: ModuleSpecifierVisit) => {
|
| 295 |
+
if (kind !== "dynamic-import") {
|
| 296 |
+
push(kind, specifierNode, specifier);
|
| 297 |
+
}
|
| 298 |
+
};
|
| 299 |
+
visitModuleSpecifiers(ts, sourceFile, visit);
|
| 300 |
+
return entries;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
async function collectOptionalClusterStaticLeaks() {
|
| 304 |
+
const files = await walkCodeFiles(srcRoot);
|
| 305 |
+
const inventory: OptionalClusterImportEntry[] = [];
|
| 306 |
+
for (const filePath of files) {
|
| 307 |
+
const relativePath = normalizePath(filePath);
|
| 308 |
+
if (relativePath.startsWith("src/plugin-sdk/")) {
|
| 309 |
+
continue;
|
| 310 |
+
}
|
| 311 |
+
const source = await fs.readFile(filePath, "utf8");
|
| 312 |
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
| 313 |
+
inventory.push(...collectOptionalClusterStaticImports(filePath, sourceFile));
|
| 314 |
+
}
|
| 315 |
+
return inventory.toSorted((left, right) => {
|
| 316 |
+
return (
|
| 317 |
+
left.cluster.localeCompare(right.cluster) ||
|
| 318 |
+
left.file.localeCompare(right.file) ||
|
| 319 |
+
left.line - right.line ||
|
| 320 |
+
left.kind.localeCompare(right.kind) ||
|
| 321 |
+
left.specifier.localeCompare(right.specifier)
|
| 322 |
+
);
|
| 323 |
+
});
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function buildDuplicatedSeamFamilies(inventory: ImportEntry[]) {
|
| 327 |
+
const grouped = new Map<string, ImportEntry[]>();
|
| 328 |
+
for (const entry of inventory) {
|
| 329 |
+
const bucket = grouped.get(entry.family) ?? [];
|
| 330 |
+
bucket.push(entry);
|
| 331 |
+
grouped.set(entry.family, bucket);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
const duplicated = Object.fromEntries(
|
| 335 |
+
[...grouped.entries()]
|
| 336 |
+
.map(([family, entries]) => {
|
| 337 |
+
const files = [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings);
|
| 338 |
+
return [
|
| 339 |
+
family,
|
| 340 |
+
{
|
| 341 |
+
count: files.length,
|
| 342 |
+
importCount: entries.length,
|
| 343 |
+
files,
|
| 344 |
+
imports: entries,
|
| 345 |
+
},
|
| 346 |
+
] as const;
|
| 347 |
+
})
|
| 348 |
+
.filter(([, value]) => value.files.length > 1)
|
| 349 |
+
.toSorted((left, right) => {
|
| 350 |
+
return (
|
| 351 |
+
right[1].count - left[1].count ||
|
| 352 |
+
right[1].importCount - left[1].importCount ||
|
| 353 |
+
left[0].localeCompare(right[0])
|
| 354 |
+
);
|
| 355 |
+
}),
|
| 356 |
+
);
|
| 357 |
+
|
| 358 |
+
return duplicated;
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
function buildOverlapFiles(inventory: ImportEntry[]) {
|
| 362 |
+
const byFile = new Map<string, ImportEntry[]>();
|
| 363 |
+
for (const entry of inventory) {
|
| 364 |
+
const bucket = byFile.get(entry.file) ?? [];
|
| 365 |
+
bucket.push(entry);
|
| 366 |
+
byFile.set(entry.file, bucket);
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
return [...byFile.entries()]
|
| 370 |
+
.map(([file, entries]) => {
|
| 371 |
+
const families = [...new Set(entries.map((entry) => entry.family))].toSorted(compareStrings);
|
| 372 |
+
return {
|
| 373 |
+
file,
|
| 374 |
+
families,
|
| 375 |
+
imports: entries,
|
| 376 |
+
};
|
| 377 |
+
})
|
| 378 |
+
.filter((entry) => entry.families.length > 1)
|
| 379 |
+
.toSorted((left, right) => {
|
| 380 |
+
return (
|
| 381 |
+
right.families.length - left.families.length ||
|
| 382 |
+
right.imports.length - left.imports.length ||
|
| 383 |
+
left.file.localeCompare(right.file)
|
| 384 |
+
);
|
| 385 |
+
});
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function buildOptionalClusterStaticLeaks(inventory: OptionalClusterImportEntry[]) {
|
| 389 |
+
const grouped = new Map<string, OptionalClusterImportEntry[]>();
|
| 390 |
+
for (const entry of inventory) {
|
| 391 |
+
const bucket = grouped.get(entry.cluster) ?? [];
|
| 392 |
+
bucket.push(entry);
|
| 393 |
+
grouped.set(entry.cluster, bucket);
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
return Object.fromEntries(
|
| 397 |
+
[...grouped.entries()]
|
| 398 |
+
.map(
|
| 399 |
+
([cluster, entries]) =>
|
| 400 |
+
[
|
| 401 |
+
cluster,
|
| 402 |
+
{
|
| 403 |
+
count: entries.length,
|
| 404 |
+
files: [...new Set(entries.map((entry) => entry.file))].toSorted(compareStrings),
|
| 405 |
+
imports: entries,
|
| 406 |
+
},
|
| 407 |
+
] as const,
|
| 408 |
+
)
|
| 409 |
+
.toSorted((left, right) => {
|
| 410 |
+
return right[1].count - left[1].count || left[0].localeCompare(right[0]);
|
| 411 |
+
}),
|
| 412 |
+
);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
function packageClusterMeta(relativePackagePath: string) {
|
| 416 |
+
if (relativePackagePath === "ui/package.json") {
|
| 417 |
+
return {
|
| 418 |
+
cluster: "ui",
|
| 419 |
+
packageName: "openclaw-control-ui",
|
| 420 |
+
packagePath: relativePackagePath,
|
| 421 |
+
reachability: "workspace-ui",
|
| 422 |
+
};
|
| 423 |
+
}
|
| 424 |
+
const cluster = path.basename(path.dirname(relativePackagePath));
|
| 425 |
+
return {
|
| 426 |
+
cluster,
|
| 427 |
+
packageName: null,
|
| 428 |
+
packagePath: relativePackagePath,
|
| 429 |
+
reachability: relativePackagePath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX)
|
| 430 |
+
? "extension-workspace"
|
| 431 |
+
: "workspace",
|
| 432 |
+
};
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
function classifyMissingPackageCluster(params: {
|
| 436 |
+
cluster: string;
|
| 437 |
+
pluginSdkEntries: string[];
|
| 438 |
+
hasStaticLeak: boolean;
|
| 439 |
+
}) {
|
| 440 |
+
if (params.hasStaticLeak) {
|
| 441 |
+
return {
|
| 442 |
+
decision: "required",
|
| 443 |
+
reason:
|
| 444 |
+
"Cluster already appears in the static graph in this audit run, so treating it as optional would be misleading.",
|
| 445 |
+
};
|
| 446 |
+
}
|
| 447 |
+
if (optionalBundledClusterSet.has(params.cluster)) {
|
| 448 |
+
if (params.cluster === "ui") {
|
| 449 |
+
return {
|
| 450 |
+
decision: "optional",
|
| 451 |
+
reason:
|
| 452 |
+
"Private UI workspace. Repo-wide CLI/plugin CI should not require UI-only packages.",
|
| 453 |
+
};
|
| 454 |
+
}
|
| 455 |
+
if (params.pluginSdkEntries.length > 0) {
|
| 456 |
+
return {
|
| 457 |
+
decision: "optional",
|
| 458 |
+
reason:
|
| 459 |
+
"Public plugin-sdk entry exists, but repo-wide default check/build should isolate this optional cluster from the static graph.",
|
| 460 |
+
};
|
| 461 |
+
}
|
| 462 |
+
return {
|
| 463 |
+
decision: "optional",
|
| 464 |
+
reason:
|
| 465 |
+
"Workspace package is intentionally not mirrored into the root dependency set by default CI policy.",
|
| 466 |
+
};
|
| 467 |
+
}
|
| 468 |
+
return {
|
| 469 |
+
decision: "required",
|
| 470 |
+
reason:
|
| 471 |
+
"Cluster is statically visible to repo-wide check/build and has not been classified optional.",
|
| 472 |
+
};
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
async function buildMissingPackages(params: { staticLeakClusters?: Set<string> } = {}) {
|
| 476 |
+
const rootPackage: PackageJson = JSON.parse(
|
| 477 |
+
await fs.readFile(path.join(repoRoot, "package.json"), "utf8"),
|
| 478 |
+
);
|
| 479 |
+
const rootDeps = new Set([
|
| 480 |
+
...Object.keys(rootPackage.dependencies ?? {}),
|
| 481 |
+
...Object.keys(rootPackage.optionalDependencies ?? {}),
|
| 482 |
+
...Object.keys(rootPackage.devDependencies ?? {}),
|
| 483 |
+
]);
|
| 484 |
+
|
| 485 |
+
const pluginSdkEntrySources = await walkCodeFiles(path.join(repoRoot, "src", "plugin-sdk"));
|
| 486 |
+
const pluginSdkReachability = new Map<string, Set<string>>();
|
| 487 |
+
for (const filePath of pluginSdkEntrySources) {
|
| 488 |
+
const source = await fs.readFile(filePath, "utf8");
|
| 489 |
+
const matches = [...source.matchAll(/from\s+"(\.\.\/\.\.\/extensions\/([^/]+)\/[^"]+)"/g)];
|
| 490 |
+
for (const match of matches) {
|
| 491 |
+
const cluster = match[2];
|
| 492 |
+
if (!cluster) {
|
| 493 |
+
continue;
|
| 494 |
+
}
|
| 495 |
+
const bucket = pluginSdkReachability.get(cluster) ?? new Set();
|
| 496 |
+
bucket.add(normalizePath(filePath));
|
| 497 |
+
pluginSdkReachability.set(cluster, bucket);
|
| 498 |
+
}
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
const output = [];
|
| 502 |
+
for (const relativePackagePath of workspacePackagePaths.toSorted(compareStrings)) {
|
| 503 |
+
const packagePath = path.join(repoRoot, relativePackagePath);
|
| 504 |
+
let pkg: PackageJson;
|
| 505 |
+
try {
|
| 506 |
+
pkg = JSON.parse(await fs.readFile(packagePath, "utf8"));
|
| 507 |
+
} catch {
|
| 508 |
+
continue;
|
| 509 |
+
}
|
| 510 |
+
const missing = Object.keys(pkg.dependencies ?? {})
|
| 511 |
+
.filter((dep) => dep !== "openclaw" && !rootDeps.has(dep))
|
| 512 |
+
.toSorted(compareStrings);
|
| 513 |
+
if (missing.length === 0) {
|
| 514 |
+
continue;
|
| 515 |
+
}
|
| 516 |
+
const meta = packageClusterMeta(relativePackagePath);
|
| 517 |
+
const pluginSdkEntries = [...(pluginSdkReachability.get(meta.cluster) ?? new Set())].toSorted(
|
| 518 |
+
compareStrings,
|
| 519 |
+
);
|
| 520 |
+
const classification = classifyMissingPackageCluster({
|
| 521 |
+
cluster: meta.cluster,
|
| 522 |
+
pluginSdkEntries,
|
| 523 |
+
hasStaticLeak: params.staticLeakClusters?.has(meta.cluster) === true,
|
| 524 |
+
});
|
| 525 |
+
output.push({
|
| 526 |
+
cluster: meta.cluster,
|
| 527 |
+
decision: classification.decision,
|
| 528 |
+
decisionReason: classification.reason,
|
| 529 |
+
packageName: pkg.name ?? meta.packageName,
|
| 530 |
+
packagePath: relativePackagePath,
|
| 531 |
+
npmSpec: redactNpmSpec(pkg.openclaw?.install?.npmSpec),
|
| 532 |
+
private: pkg.private === true,
|
| 533 |
+
pluginSdkReachability:
|
| 534 |
+
pluginSdkEntries.length > 0 ? { staticEntryPoints: pluginSdkEntries } : undefined,
|
| 535 |
+
missing,
|
| 536 |
+
});
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
return output.toSorted((left, right) => {
|
| 540 |
+
return right.missing.length - left.missing.length || left.cluster.localeCompare(right.cluster);
|
| 541 |
+
});
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
function stemFromRelativePath(relativePath: string) {
|
| 545 |
+
return relativePath.replace(/\.(m|c)?[jt]sx?$/, "");
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function splitNameTokens(name: string) {
|
| 549 |
+
return name
|
| 550 |
+
.split(/[^a-zA-Z0-9]+/)
|
| 551 |
+
.map((token) => token.trim().toLowerCase())
|
| 552 |
+
.filter(Boolean);
|
| 553 |
+
}
|
| 554 |
+
|
| 555 |
+
function hasImportSource(source: string, specifier: string, matchSuffix = false) {
|
| 556 |
+
const escaped = escapeRegExp(specifier);
|
| 557 |
+
const importSource = matchSuffix ? `(?:[^"']*/)?${escaped}` : escaped;
|
| 558 |
+
return new RegExp(
|
| 559 |
+
`from\\s+["']${importSource}["']|import\\s*\\(\\s*["']${importSource}["']\\s*\\)`,
|
| 560 |
+
).test(source);
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
function hasAnyImportSource(source: string, specifiers: string[]) {
|
| 564 |
+
return specifiers.some((specifier) => hasImportSource(source, specifier));
|
| 565 |
+
}
|
| 566 |
+
|
| 567 |
+
function isCronProductionPath(relativePath: string) {
|
| 568 |
+
return relativePath.startsWith("src/cron/") && isProductionLikeFile(relativePath);
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
function isSubagentProductionPath(relativePath: string) {
|
| 572 |
+
return (
|
| 573 |
+
(relativePath.startsWith("src/agents/") || relativePath.startsWith("src/cron/")) &&
|
| 574 |
+
isProductionLikeFile(relativePath) &&
|
| 575 |
+
(/subagent|sessions-spawn|acp-spawn/.test(relativePath) ||
|
| 576 |
+
relativePath === "src/agents/tools/sessions-spawn-tool.ts" ||
|
| 577 |
+
relativePath === "src/agents/tools/subagents-tool.ts")
|
| 578 |
+
);
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
function describeCronSeamKinds(relativePath: string, source: string) {
|
| 582 |
+
if (!isCronProductionPath(relativePath)) {
|
| 583 |
+
return [];
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
const seamKinds = [];
|
| 587 |
+
const importsAgentRunner = hasAnyImportSource(source, [
|
| 588 |
+
"../../agents/cli-runner.js",
|
| 589 |
+
"../../agents/embedded-agent.js",
|
| 590 |
+
"../../agents/model-fallback.js",
|
| 591 |
+
"../../agents/subagent-registry.js",
|
| 592 |
+
"../../infra/agent-events.js",
|
| 593 |
+
]);
|
| 594 |
+
const importsOutboundDelivery = hasAnyImportSource(source, [
|
| 595 |
+
"../infra/outbound/deliver.js",
|
| 596 |
+
"../../infra/outbound/deliver.js",
|
| 597 |
+
"../infra/outbound/session-context.js",
|
| 598 |
+
"../../infra/outbound/session-context.js",
|
| 599 |
+
"../infra/outbound/identity.js",
|
| 600 |
+
"../../infra/outbound/identity.js",
|
| 601 |
+
"../cli/outbound-send-deps.js",
|
| 602 |
+
"../../cli/outbound-send-deps.js",
|
| 603 |
+
]);
|
| 604 |
+
const importsHeartbeat = hasAnyImportSource(source, [
|
| 605 |
+
"../auto-reply/heartbeat.js",
|
| 606 |
+
"../../auto-reply/heartbeat.js",
|
| 607 |
+
"../infra/heartbeat-wake.js",
|
| 608 |
+
"../../infra/heartbeat-wake.js",
|
| 609 |
+
]);
|
| 610 |
+
const importsFollowup = hasAnyImportSource(source, [
|
| 611 |
+
"./subagent-followup.js",
|
| 612 |
+
"../../agents/subagent-registry.js",
|
| 613 |
+
"../../agents/tools/agent-step.js",
|
| 614 |
+
"../../gateway/call.js",
|
| 615 |
+
]);
|
| 616 |
+
const importsSchedulerModules =
|
| 617 |
+
relativePath.startsWith("src/cron/service/") &&
|
| 618 |
+
hasAnyImportSource(source, [
|
| 619 |
+
"./jobs.js",
|
| 620 |
+
"./store.js",
|
| 621 |
+
"./timer.js",
|
| 622 |
+
"./state.js",
|
| 623 |
+
"../schedule.js",
|
| 624 |
+
"../store.js",
|
| 625 |
+
]);
|
| 626 |
+
|
| 627 |
+
if (
|
| 628 |
+
importsAgentRunner &&
|
| 629 |
+
/\brunCliAgent\b|\brunEmbeddedAgent\b|\brunWithModelFallback\b|\bregisterAgentRunContext\b/.test(
|
| 630 |
+
source,
|
| 631 |
+
)
|
| 632 |
+
) {
|
| 633 |
+
seamKinds.push("cron-agent-handoff");
|
| 634 |
+
}
|
| 635 |
+
|
| 636 |
+
if (
|
| 637 |
+
importsOutboundDelivery &&
|
| 638 |
+
/\bdeliverOutboundPayloads\b|\bbuildOutboundSessionContext\b|\bresolveAgentOutboundIdentity\b/.test(
|
| 639 |
+
source,
|
| 640 |
+
)
|
| 641 |
+
) {
|
| 642 |
+
seamKinds.push("cron-outbound-delivery");
|
| 643 |
+
}
|
| 644 |
+
|
| 645 |
+
if (
|
| 646 |
+
importsHeartbeat &&
|
| 647 |
+
/\bstripHeartbeatToken\b|\bHeartbeat\b|\bheartbeat\b|\bnext-heartbeat\b/.test(source)
|
| 648 |
+
) {
|
| 649 |
+
seamKinds.push("cron-heartbeat-handoff");
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
if (
|
| 653 |
+
importsSchedulerModules &&
|
| 654 |
+
/\bensureLoaded\b|\bpersist\b|\barmTimer\b|\brunMissedJobs\b|\bcomputeJobNextRunAtMs\b|\brecomputeNextRuns\b|\bnextWakeAtMs\b/.test(
|
| 655 |
+
source,
|
| 656 |
+
)
|
| 657 |
+
) {
|
| 658 |
+
seamKinds.push("cron-scheduler-state");
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
if (
|
| 662 |
+
importsOutboundDelivery &&
|
| 663 |
+
/\bmediaUrl\b|\bmediaUrls\b|\bfilename\b|\baudioAsVoice\b|\bdeliveryPayloads\b|\bdeliveryPayloadHasStructuredContent\b/.test(
|
| 664 |
+
source,
|
| 665 |
+
)
|
| 666 |
+
) {
|
| 667 |
+
seamKinds.push("cron-media-delivery");
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
if (
|
| 671 |
+
importsFollowup &&
|
| 672 |
+
/\bwaitForDescendantSubagentSummary\b|\breadDescendantSubagentFallbackReply\b|\bexpectsSubagentFollowup\b|\bcallGateway\b|\blistDescendantRunsForRequester\b/.test(
|
| 673 |
+
source,
|
| 674 |
+
)
|
| 675 |
+
) {
|
| 676 |
+
seamKinds.push("cron-followup-handoff");
|
| 677 |
+
}
|
| 678 |
+
|
| 679 |
+
return seamKinds;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
+
function describeSubagentSeamKinds(relativePath: string, source: string) {
|
| 683 |
+
if (!isSubagentProductionPath(relativePath)) {
|
| 684 |
+
return [];
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
const seamKinds = [];
|
| 688 |
+
const isAnnounceDispatchPath =
|
| 689 |
+
relativePath === "src/agents/subagents/announce/subagent-announce.ts" ||
|
| 690 |
+
relativePath === "src/agents/subagents/announce/subagent-announce-dispatch.ts";
|
| 691 |
+
const importsSpawnRuntime = hasAnyImportSource(source, [
|
| 692 |
+
"./subagent-spawn.js",
|
| 693 |
+
"./acp-spawn.js",
|
| 694 |
+
"./subagents/spawn/subagent-spawn.js",
|
| 695 |
+
"../subagents/spawn/subagent-spawn.js",
|
| 696 |
+
"./subagents/spawn/acp-spawn.js",
|
| 697 |
+
"../subagents/spawn/acp-spawn.js",
|
| 698 |
+
"./subagent-registry.js",
|
| 699 |
+
"../subagent-registry.js",
|
| 700 |
+
"../../subagent-registry.js",
|
| 701 |
+
"../acp/control-plane/manager.js",
|
| 702 |
+
"../../../acp/control-plane/manager.js",
|
| 703 |
+
]);
|
| 704 |
+
const importsLifecycleRegistry =
|
| 705 |
+
hasImportSource(source, "subagent-registry.js", true) ||
|
| 706 |
+
hasAnyImportSource(source, [
|
| 707 |
+
"./subagent-registry-completion.js",
|
| 708 |
+
"./subagent-registry-cleanup.js",
|
| 709 |
+
"./subagent-registry-state.js",
|
| 710 |
+
"./subagent-lifecycle-events.js",
|
| 711 |
+
"../context-engine/init.js",
|
| 712 |
+
"../context-engine/registry.js",
|
| 713 |
+
"../sessions/session-lifecycle-events.js",
|
| 714 |
+
"../../../context-engine/init.js",
|
| 715 |
+
"../../../context-engine/registry.js",
|
| 716 |
+
"../../../sessions/session-lifecycle-events.js",
|
| 717 |
+
]);
|
| 718 |
+
const importsAnnounceDelivery = hasAnyImportSource(source, [
|
| 719 |
+
"./subagent-announce.js",
|
| 720 |
+
"./subagent-announce-dispatch.js",
|
| 721 |
+
"../announce/subagent-announce.js",
|
| 722 |
+
"../announce/subagent-announce-dispatch.js",
|
| 723 |
+
"./subagent-announce-queue.js",
|
| 724 |
+
"../infra/outbound/bound-delivery-router.js",
|
| 725 |
+
"../utils/delivery-context.shared.js",
|
| 726 |
+
"../gateway/call.js",
|
| 727 |
+
"../../../infra/outbound/bound-delivery-router.js",
|
| 728 |
+
"../../../utils/delivery-context.shared.js",
|
| 729 |
+
"../../../gateway/call.js",
|
| 730 |
+
]);
|
| 731 |
+
const importsCleanup = hasAnyImportSource(source, [
|
| 732 |
+
"../gateway/call.js",
|
| 733 |
+
"./subagent-registry-cleanup.js",
|
| 734 |
+
"../acp/control-plane/spawn.js",
|
| 735 |
+
"../../../gateway/call.js",
|
| 736 |
+
"../../../acp/control-plane/spawn.js",
|
| 737 |
+
]);
|
| 738 |
+
const importsParentStream = hasAnyImportSource(source, [
|
| 739 |
+
"./acp-spawn-parent-stream.js",
|
| 740 |
+
"../infra/heartbeat-wake.js",
|
| 741 |
+
"../infra/system-events.js",
|
| 742 |
+
"../infra/agent-events.js",
|
| 743 |
+
"../../../infra/heartbeat-wake.js",
|
| 744 |
+
"../../../infra/system-events.js",
|
| 745 |
+
"../../../infra/agent-events.js",
|
| 746 |
+
]);
|
| 747 |
+
|
| 748 |
+
if (
|
| 749 |
+
importsSpawnRuntime &&
|
| 750 |
+
/\bspawnSubagentDirect\b|\bspawnAcpDirect\b|\bregisterSubagentRun\b|\bgetAcpSessionManager\b|\bspawnSubagent\b|\bspawnAcp\b/.test(
|
| 751 |
+
source,
|
| 752 |
+
)
|
| 753 |
+
) {
|
| 754 |
+
seamKinds.push("subagent-session-spawn");
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
if (
|
| 758 |
+
importsLifecycleRegistry &&
|
| 759 |
+
/\bemitSubagentEndedHookOnce\b|\bresolveDeferredCleanupDecision\b|\bpersistSubagentRunsToDisk\b|\brestoreSubagentRunsFromDisk\b|\bresolveContextEngine\b|\bemitSessionLifecycleEvent\b|\bcaptureSubagentCompletionReply\b/.test(
|
| 760 |
+
source,
|
| 761 |
+
)
|
| 762 |
+
) {
|
| 763 |
+
seamKinds.push("subagent-lifecycle-registry");
|
| 764 |
+
}
|
| 765 |
+
|
| 766 |
+
if (
|
| 767 |
+
(importsAnnounceDelivery || isAnnounceDispatchPath) &&
|
| 768 |
+
/\brunSubagentAnnounceFlow\b|\brunSubagentAnnounceDispatch\b|\benqueueAnnounce\b|\bcreateBoundDeliveryRouter\b|\bqueueEmbeddedAgentMessage\b|\bwaitForEmbeddedAgentRunEnd\b|\bqueue-fallback\b|\bdirect-primary\b/.test(
|
| 769 |
+
source,
|
| 770 |
+
)
|
| 771 |
+
) {
|
| 772 |
+
seamKinds.push("subagent-announce-delivery");
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
if (
|
| 776 |
+
importsCleanup &&
|
| 777 |
+
/\bsessions\.delete\b|\bdeleteTranscript\b|\bcleanupFailedAcpSpawn\b|\bcleanupProvisionalSession\b|\bcleanupFailedSpawnBeforeAgentStart\b|\bresolveDeferredCleanupDecision\b/.test(
|
| 778 |
+
source,
|
| 779 |
+
)
|
| 780 |
+
) {
|
| 781 |
+
seamKinds.push("subagent-session-cleanup");
|
| 782 |
+
}
|
| 783 |
+
|
| 784 |
+
if (
|
| 785 |
+
importsParentStream &&
|
| 786 |
+
/\bstartAcpSpawnParentStreamRelay\b|\brequestHeartbeatNow\b|\benqueueSystemEvent\b|\bonAgentEvent\b|\bstreamTo\b/.test(
|
| 787 |
+
source,
|
| 788 |
+
)
|
| 789 |
+
) {
|
| 790 |
+
seamKinds.push("subagent-parent-stream");
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
return seamKinds;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
export function describeSeamKinds(relativePath: string, source: string) {
|
| 797 |
+
const seamKinds = [];
|
| 798 |
+
const isReplyDeliveryPath =
|
| 799 |
+
/reply-delivery|reply-dispatcher|deliver-reply|reply\/.*delivery|monitor\/(?:replies|deliver|native-command)|outbound\/deliver|outbound\/message/.test(
|
| 800 |
+
relativePath,
|
| 801 |
+
);
|
| 802 |
+
const isChannelMediaAdapterPath =
|
| 803 |
+
(relativePath.startsWith(BUNDLED_PLUGIN_PATH_PREFIX) &&
|
| 804 |
+
/(outbound|outbound-adapter|reply-delivery|send|delivery|messenger|channel(?:\.runtime)?)\.ts$/.test(
|
| 805 |
+
relativePath,
|
| 806 |
+
)) ||
|
| 807 |
+
/^src\/channels\/plugins\/outbound\/[^/]+\.ts$/.test(relativePath);
|
| 808 |
+
if (
|
| 809 |
+
relativePath.startsWith("src/agents/tools/") &&
|
| 810 |
+
source.includes("details") &&
|
| 811 |
+
source.includes("media") &&
|
| 812 |
+
/details\s*:\s*{[\s\S]*\bmedia\b\s*:/.test(source)
|
| 813 |
+
) {
|
| 814 |
+
seamKinds.push("tool-result-media");
|
| 815 |
+
}
|
| 816 |
+
if (
|
| 817 |
+
isReplyDeliveryPath &&
|
| 818 |
+
/\bmediaUrl\b|\bmediaUrls\b|resolveSendableOutboundReplyParts/.test(source)
|
| 819 |
+
) {
|
| 820 |
+
seamKinds.push("reply-delivery-media");
|
| 821 |
+
}
|
| 822 |
+
if (
|
| 823 |
+
isChannelMediaAdapterPath &&
|
| 824 |
+
(/sendMedia\b/.test(source) || /\bmediaUrl\b|\bmediaUrls\b|filename|audioAsVoice/.test(source))
|
| 825 |
+
) {
|
| 826 |
+
seamKinds.push("channel-media-adapter");
|
| 827 |
+
}
|
| 828 |
+
if (
|
| 829 |
+
isReplyDeliveryPath &&
|
| 830 |
+
/blockStreamingEnabled|directlySentBlockKeys|resolveSendableOutboundReplyParts/.test(source) &&
|
| 831 |
+
/\bmediaUrl\b|\bmediaUrls\b/.test(source)
|
| 832 |
+
) {
|
| 833 |
+
seamKinds.push("streaming-media-handoff");
|
| 834 |
+
}
|
| 835 |
+
seamKinds.push(...describeCronSeamKinds(relativePath, source));
|
| 836 |
+
seamKinds.push(...describeSubagentSeamKinds(relativePath, source));
|
| 837 |
+
return [...new Set(seamKinds)].toSorted(compareStrings);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
async function buildTestIndex(testFiles: string[]) {
|
| 841 |
+
return Promise.all(
|
| 842 |
+
testFiles.map(async (filePath) => {
|
| 843 |
+
const relativePath = normalizePath(filePath);
|
| 844 |
+
const stem = stemFromRelativePath(relativePath)
|
| 845 |
+
.replace(/\.test$/, "")
|
| 846 |
+
.replace(/\.spec$/, "");
|
| 847 |
+
const baseName = path.basename(stem);
|
| 848 |
+
const source = await readScannableText(filePath);
|
| 849 |
+
return {
|
| 850 |
+
filePath,
|
| 851 |
+
relativePath,
|
| 852 |
+
stem,
|
| 853 |
+
baseName,
|
| 854 |
+
source,
|
| 855 |
+
};
|
| 856 |
+
}),
|
| 857 |
+
);
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
type TestIndexEntry = Awaited<ReturnType<typeof buildTestIndex>>[number];
|
| 861 |
+
|
| 862 |
+
function hasExecutableImportReference(source: string, importPath: string) {
|
| 863 |
+
const escapedImportPath = escapeRegExp(importPath);
|
| 864 |
+
const suffix = String.raw`(?:\.[^"'\\\`]+)?`;
|
| 865 |
+
const patterns = [
|
| 866 |
+
new RegExp(String.raw`\bfrom\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
|
| 867 |
+
new RegExp(String.raw`\bimport\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
|
| 868 |
+
new RegExp(String.raw`\brequire\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]\s*\)`),
|
| 869 |
+
new RegExp(String.raw`\bimport\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]\s*\)`),
|
| 870 |
+
];
|
| 871 |
+
return patterns.some((pattern) => pattern.test(source));
|
| 872 |
+
}
|
| 873 |
+
|
| 874 |
+
function hasModuleMockReference(source: string, importPath: string) {
|
| 875 |
+
const escapedImportPath = escapeRegExp(importPath);
|
| 876 |
+
const suffix = String.raw`(?:\.[^"'\\\`]+)?`;
|
| 877 |
+
const patterns = [
|
| 878 |
+
new RegExp(String.raw`\bvi\.mock\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
|
| 879 |
+
new RegExp(String.raw`\bjest\.mock\s*\(\s*["'\`]${escapedImportPath}${suffix}["'\`]`),
|
| 880 |
+
];
|
| 881 |
+
return patterns.some((pattern) => pattern.test(source));
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
const matchQualityRank = (quality: MatchQuality) => MATCH_QUALITY_RANK[quality] ?? 4;
|
| 885 |
+
|
| 886 |
+
function findRelatedTests(relativePath: string, testIndex: TestIndexEntry[]): RelatedTestMatch[] {
|
| 887 |
+
const stem = stemFromRelativePath(relativePath);
|
| 888 |
+
const baseName = path.basename(stem);
|
| 889 |
+
const dirName = path.dirname(relativePath);
|
| 890 |
+
const normalizedDir = dirName.split(path.sep).join("/");
|
| 891 |
+
const baseTokens = new Set(splitNameTokens(baseName).filter((token) => token.length >= 7));
|
| 892 |
+
|
| 893 |
+
const matches: RelatedTestMatch[] = testIndex.flatMap((entry): RelatedTestMatch[] => {
|
| 894 |
+
if (entry.stem === stem) {
|
| 895 |
+
return [{ file: entry.relativePath, matchQuality: "exact-stem" }];
|
| 896 |
+
}
|
| 897 |
+
if (entry.stem.startsWith(`${stem}.`)) {
|
| 898 |
+
return [{ file: entry.relativePath, matchQuality: "path-nearby" }];
|
| 899 |
+
}
|
| 900 |
+
const entryDir = path.dirname(entry.relativePath).split(path.sep).join("/");
|
| 901 |
+
const importPath =
|
| 902 |
+
path.posix.relative(entryDir, stem) === path.basename(stem)
|
| 903 |
+
? `./${path.basename(stem)}`
|
| 904 |
+
: path.posix.relative(entryDir, stem).startsWith(".")
|
| 905 |
+
? path.posix.relative(entryDir, stem)
|
| 906 |
+
: `./${path.posix.relative(entryDir, stem)}`;
|
| 907 |
+
if (
|
| 908 |
+
hasExecutableImportReference(entry.source, importPath) &&
|
| 909 |
+
!hasModuleMockReference(entry.source, importPath)
|
| 910 |
+
) {
|
| 911 |
+
return [{ file: entry.relativePath, matchQuality: "direct-import" }];
|
| 912 |
+
}
|
| 913 |
+
if (entryDir === normalizedDir && baseTokens.size > 0) {
|
| 914 |
+
const entryTokens = splitNameTokens(entry.baseName);
|
| 915 |
+
const sharedToken = entryTokens.find((token) => baseTokens.has(token));
|
| 916 |
+
if (sharedToken) {
|
| 917 |
+
return [{ file: entry.relativePath, matchQuality: "dir-token" }];
|
| 918 |
+
}
|
| 919 |
+
}
|
| 920 |
+
return [];
|
| 921 |
+
});
|
| 922 |
+
|
| 923 |
+
const byFile = new Map<string, RelatedTestMatch>();
|
| 924 |
+
for (const match of matches) {
|
| 925 |
+
const existing = byFile.get(match.file);
|
| 926 |
+
if (
|
| 927 |
+
!existing ||
|
| 928 |
+
matchQualityRank(match.matchQuality) < matchQualityRank(existing.matchQuality)
|
| 929 |
+
) {
|
| 930 |
+
byFile.set(match.file, match);
|
| 931 |
+
}
|
| 932 |
+
}
|
| 933 |
+
|
| 934 |
+
return [...byFile.values()].toSorted((left, right) => {
|
| 935 |
+
return (
|
| 936 |
+
matchQualityRank(left.matchQuality) - matchQualityRank(right.matchQuality) ||
|
| 937 |
+
left.file.localeCompare(right.file)
|
| 938 |
+
);
|
| 939 |
+
});
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
export function determineSeamTestStatus(
|
| 943 |
+
seamKinds: string[],
|
| 944 |
+
relatedTestMatches: RelatedTestMatch[],
|
| 945 |
+
) {
|
| 946 |
+
if (relatedTestMatches.length === 0) {
|
| 947 |
+
return {
|
| 948 |
+
status: "gap",
|
| 949 |
+
reason: "No nearby test file references this seam candidate.",
|
| 950 |
+
};
|
| 951 |
+
}
|
| 952 |
+
|
| 953 |
+
const bestMatch = relatedTestMatches[0]?.matchQuality ?? "unknown";
|
| 954 |
+
if (
|
| 955 |
+
seamKinds.includes("reply-delivery-media") ||
|
| 956 |
+
seamKinds.includes("streaming-media-handoff") ||
|
| 957 |
+
seamKinds.includes("tool-result-media") ||
|
| 958 |
+
seamKinds.includes("cron-agent-handoff") ||
|
| 959 |
+
seamKinds.includes("cron-outbound-delivery") ||
|
| 960 |
+
seamKinds.includes("cron-heartbeat-handoff") ||
|
| 961 |
+
seamKinds.includes("cron-scheduler-state") ||
|
| 962 |
+
seamKinds.includes("cron-media-delivery") ||
|
| 963 |
+
seamKinds.includes("cron-followup-handoff") ||
|
| 964 |
+
seamKinds.includes("subagent-session-spawn") ||
|
| 965 |
+
seamKinds.includes("subagent-lifecycle-registry") ||
|
| 966 |
+
seamKinds.includes("subagent-announce-delivery") ||
|
| 967 |
+
seamKinds.includes("subagent-session-cleanup") ||
|
| 968 |
+
seamKinds.includes("subagent-parent-stream")
|
| 969 |
+
) {
|
| 970 |
+
return {
|
| 971 |
+
status: "partial",
|
| 972 |
+
reason: `Nearby tests exist (best match: ${bestMatch}), but this inventory does not prove cross-layer seam coverage end to end.`,
|
| 973 |
+
};
|
| 974 |
+
}
|
| 975 |
+
return {
|
| 976 |
+
status: "heuristic-nearby",
|
| 977 |
+
reason: `Nearby tests exist (best match: ${bestMatch}), but this remains a filename/path heuristic rather than proof of seam assertions.`,
|
| 978 |
+
};
|
| 979 |
+
}
|
| 980 |
+
|
| 981 |
+
async function buildSeamTestInventory() {
|
| 982 |
+
const productionFiles = [
|
| 983 |
+
...(await walkCodeFiles(srcRoot)),
|
| 984 |
+
...(await walkCodeFiles(extensionsRoot)),
|
| 985 |
+
].toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
| 986 |
+
const testFiles = [
|
| 987 |
+
...(await walkAllCodeFiles(srcRoot, { includeTests: true })),
|
| 988 |
+
...(await walkAllCodeFiles(extensionsRoot, { includeTests: true })),
|
| 989 |
+
...(await walkAllCodeFiles(testRoot, { includeTests: true })),
|
| 990 |
+
]
|
| 991 |
+
.filter((filePath) => /\.(test|spec)\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/.test(filePath))
|
| 992 |
+
.toSorted((left, right) => normalizePath(left).localeCompare(normalizePath(right)));
|
| 993 |
+
const testIndex = await buildTestIndex(testFiles);
|
| 994 |
+
const inventory = [];
|
| 995 |
+
|
| 996 |
+
for (const filePath of productionFiles) {
|
| 997 |
+
const relativePath = normalizePath(filePath);
|
| 998 |
+
const source = await readScannableText(filePath);
|
| 999 |
+
const seamKinds = describeSeamKinds(relativePath, source);
|
| 1000 |
+
if (seamKinds.length === 0) {
|
| 1001 |
+
continue;
|
| 1002 |
+
}
|
| 1003 |
+
const relatedTestMatches = findRelatedTests(relativePath, testIndex);
|
| 1004 |
+
const status = determineSeamTestStatus(seamKinds, relatedTestMatches);
|
| 1005 |
+
inventory.push({
|
| 1006 |
+
file: relativePath,
|
| 1007 |
+
seamKinds,
|
| 1008 |
+
relatedTests: relatedTestMatches.map((entry) => entry.file),
|
| 1009 |
+
relatedTestMatches,
|
| 1010 |
+
status: status.status,
|
| 1011 |
+
reason: status.reason,
|
| 1012 |
+
});
|
| 1013 |
+
}
|
| 1014 |
+
|
| 1015 |
+
return inventory.toSorted((left, right) => {
|
| 1016 |
+
return (
|
| 1017 |
+
left.status.localeCompare(right.status) ||
|
| 1018 |
+
left.file.localeCompare(right.file) ||
|
| 1019 |
+
left.seamKinds.join(",").localeCompare(right.seamKinds.join(","))
|
| 1020 |
+
);
|
| 1021 |
+
});
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
export async function main(argv: string[] = process.argv.slice(2)) {
|
| 1025 |
+
const args = new Set(argv);
|
| 1026 |
+
if (args.has("--help") || args.has("-h")) {
|
| 1027 |
+
process.stdout.write(`${HELP_TEXT}\n`);
|
| 1028 |
+
return;
|
| 1029 |
+
}
|
| 1030 |
+
|
| 1031 |
+
await collectWorkspacePackagePaths();
|
| 1032 |
+
const inventory = await collectCorePluginSdkImports();
|
| 1033 |
+
const optionalClusterStaticLeaks = await collectOptionalClusterStaticLeaks();
|
| 1034 |
+
const staticLeakClusters = new Set(optionalClusterStaticLeaks.map((entry) => entry.cluster));
|
| 1035 |
+
const result = {
|
| 1036 |
+
duplicatedSeamFamilies: buildDuplicatedSeamFamilies(inventory),
|
| 1037 |
+
overlapFiles: buildOverlapFiles(inventory),
|
| 1038 |
+
optionalClusterStaticLeaks: buildOptionalClusterStaticLeaks(optionalClusterStaticLeaks),
|
| 1039 |
+
missingPackages: await buildMissingPackages({ staticLeakClusters }),
|
| 1040 |
+
seamTestInventory: await buildSeamTestInventory(),
|
| 1041 |
+
};
|
| 1042 |
+
|
| 1043 |
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
| 1044 |
+
}
|
| 1045 |
+
|
| 1046 |
+
const entryFilePath = process.argv[1] ? path.resolve(process.argv[1]) : null;
|
| 1047 |
+
if (entryFilePath === fileURLToPath(import.meta.url)) {
|
| 1048 |
+
await main();
|
| 1049 |
+
}
|
scripts/authorized-beta-focused-policy.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema": "openclaw.authorized-beta-focused-policy.v1",
|
| 3 |
+
"mode": "authorized-beta-focused-v1",
|
| 4 |
+
"releaseTag": "v2026.8.1-beta.3",
|
| 5 |
+
"releaseVersion": "2026.8.1-beta.3",
|
| 6 |
+
"baseCandidateSha": "3203a6f7f8d79644fde2b4f091a694f4c1698538",
|
| 7 |
+
"candidateSha": "3fbe94065c2b94f4c08acb6742a69938bf408d94",
|
| 8 |
+
"reviewedHeadSha": "e347223a5858bda5907acda858404e5f7a4c3720",
|
| 9 |
+
"candidateTreeSha": "71fb83bcab002be3da4057e7cffacf77d4cdbd2d",
|
| 10 |
+
"baseTreeSha": "10d1f3ab0ff52383c025c5846427e0dd5d14598a",
|
| 11 |
+
"packageProjectionSha256": "d1d8f60b8a14aeb567a0d5215a6b0265c74a07ebea04b288e43c31cba473bbca",
|
| 12 |
+
"eligibilityPlanDigest": "sha256:e05226cfd77716b262882b3e2525037a506cd8b6af2affa0a876499074b1671b",
|
| 13 |
+
"historicalToolingSha": "eed27fdb88c0c60cb79c5b159a0284bd519271c2",
|
| 14 |
+
"historicalToolingRef": "refs/tags/release-publish/eed27fdb88c0-1787493831",
|
| 15 |
+
"historicalFrv": {
|
| 16 |
+
"runId": "32644377679",
|
| 17 |
+
"runAttempt": 1,
|
| 18 |
+
"workflowPath": ".github/workflows/full-release-validation.yml",
|
| 19 |
+
"workflowRef": "release-ci/eed27fdb88c0-1787493942617",
|
| 20 |
+
"targetSha": "3203a6f7f8d79644fde2b4f091a694f4c1698538",
|
| 21 |
+
"ciRunId": "32644407381",
|
| 22 |
+
"ciFailedJobId": "97206458686",
|
| 23 |
+
"ciAggregateJobId": "97216945772",
|
| 24 |
+
"pluginRunId": "32645134710",
|
| 25 |
+
"pluginFailedJobId": "97208293666",
|
| 26 |
+
"pluginAggregateJobId": "97210400113",
|
| 27 |
+
"releaseChecksRunId": "32645133620",
|
| 28 |
+
"releaseChecksVerifierJobId": "97211299203",
|
| 29 |
+
"performanceRunId": "32644407718",
|
| 30 |
+
"performanceFailedJobId": "97206336416"
|
| 31 |
+
},
|
| 32 |
+
"focusedProof": {
|
| 33 |
+
"ciRunId": "32664685168",
|
| 34 |
+
"ciTargetLogJobId": "97256163016",
|
| 35 |
+
"ciSuccessJobId": "97256296219",
|
| 36 |
+
"pluginRunId": "32664686635",
|
| 37 |
+
"pluginTargetLogJobId": "97256164326",
|
| 38 |
+
"pluginSuccessJobId": "97256329353"
|
| 39 |
+
},
|
| 40 |
+
"changedPaths": [
|
| 41 |
+
{
|
| 42 |
+
"path": "extensions/slack/src/monitor.test-helpers.ts",
|
| 43 |
+
"status": "M",
|
| 44 |
+
"added": 12,
|
| 45 |
+
"deleted": 18
|
| 46 |
+
},
|
| 47 |
+
{
|
| 48 |
+
"path": "extensions/slack/src/monitor/provider.auth-test-token.test.ts",
|
| 49 |
+
"status": "M",
|
| 50 |
+
"added": 10,
|
| 51 |
+
"deleted": 569
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
"path": "extensions/slack/src/monitor/provider.event-identity-recovery.test.ts",
|
| 55 |
+
"status": "D",
|
| 56 |
+
"added": 0,
|
| 57 |
+
"deleted": 155
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"path": "extensions/slack/src/monitor/provider.identity.test.ts",
|
| 61 |
+
"status": "A",
|
| 62 |
+
"added": 658,
|
| 63 |
+
"deleted": 0
|
| 64 |
+
}
|
| 65 |
+
],
|
| 66 |
+
"inventory": {
|
| 67 |
+
"npmCount": 93,
|
| 68 |
+
"npmNamesSha256": "778eb6aeb324c02942e17755557ff9d5055376fb4d39096a60022f0ae75d67f3",
|
| 69 |
+
"clawHubCount": 89,
|
| 70 |
+
"clawHubNamesSha256": "1b161a4fde3c003e23db306d4a86908086caac1cd33e75feeaf27474366c1c2b",
|
| 71 |
+
"trustedPublisherCount": 75,
|
| 72 |
+
"trustedPublisherNamesSha256": "4266416f20fe5c8b7a4ce732121a2206fc8cfa6af25deae0aec70d552ceb5df8",
|
| 73 |
+
"bootstrapCount": 14,
|
| 74 |
+
"bootstrapNamesSha256": "83c79c6099d12dc2ebb58e2fc5ca8e078776dfa21b50dee741bac6aed5526eef",
|
| 75 |
+
"missingTrustedPublisherCount": 0
|
| 76 |
+
}
|
| 77 |
+
}
|
scripts/bench-agent-concurrency-worker.ts
ADDED
|
@@ -0,0 +1,836 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { performance } from "node:perf_hooks";
|
| 5 |
+
import { pathToFileURL } from "node:url";
|
| 6 |
+
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
|
| 7 |
+
import type { SubagentRunRecord } from "../src/agents/subagents/registry/subagent-registry.types.js";
|
| 8 |
+
import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js";
|
| 9 |
+
import {
|
| 10 |
+
WORKER_RESULT_SENTINEL,
|
| 11 |
+
type WorkerResult,
|
| 12 |
+
type WorkerScenario,
|
| 13 |
+
} from "./bench-agent-concurrency.js";
|
| 14 |
+
import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts";
|
| 15 |
+
|
| 16 |
+
type WorkerOptions = {
|
| 17 |
+
scenario: WorkerScenario;
|
| 18 |
+
size: number;
|
| 19 |
+
runs: number;
|
| 20 |
+
warmup: number;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
type Sample = {
|
| 24 |
+
durationMs: number;
|
| 25 |
+
invariant: Record<string, number | boolean>;
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const SCENARIOS = new Set<WorkerScenario>([
|
| 29 |
+
"spawnPipelineInMemory",
|
| 30 |
+
"spawnPipelineDurable",
|
| 31 |
+
"admission",
|
| 32 |
+
"recoverySweep",
|
| 33 |
+
"duplicateSuppression",
|
| 34 |
+
]);
|
| 35 |
+
|
| 36 |
+
function parseInteger(raw: string | undefined, flag: string, min: number, max: number): number {
|
| 37 |
+
const result = classifyBoundedUnsignedDecimal(raw, min, max);
|
| 38 |
+
if (result.kind === "syntax") {
|
| 39 |
+
throw new Error(`${flag} must be an integer`);
|
| 40 |
+
}
|
| 41 |
+
if (result.kind !== "value") {
|
| 42 |
+
throw new Error(`${flag} must be between ${min} and ${max}`);
|
| 43 |
+
}
|
| 44 |
+
return result.value;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
function parseOptions(argv: string[]): WorkerOptions {
|
| 48 |
+
const values = new Map<string, string>();
|
| 49 |
+
for (let index = 0; index < argv.length; index += 2) {
|
| 50 |
+
const flag = argv[index];
|
| 51 |
+
const value = argv[index + 1];
|
| 52 |
+
if (!flag?.startsWith("--") || !value || value.startsWith("--")) {
|
| 53 |
+
throw new Error(`invalid worker argument near ${flag ?? "end"}`);
|
| 54 |
+
}
|
| 55 |
+
if (values.has(flag)) {
|
| 56 |
+
throw new Error(`${flag} was provided more than once`);
|
| 57 |
+
}
|
| 58 |
+
values.set(flag, value);
|
| 59 |
+
}
|
| 60 |
+
const scenario = values.get("--scenario") as WorkerScenario | undefined;
|
| 61 |
+
if (!scenario || !SCENARIOS.has(scenario)) {
|
| 62 |
+
throw new Error(`unknown worker scenario: ${scenario ?? "missing"}`);
|
| 63 |
+
}
|
| 64 |
+
return {
|
| 65 |
+
scenario,
|
| 66 |
+
size: parseInteger(values.get("--size"), "--size", 1, 4096),
|
| 67 |
+
runs: parseInteger(values.get("--runs"), "--runs", 1, 100),
|
| 68 |
+
warmup: parseInteger(values.get("--warmup"), "--warmup", 0, 20),
|
| 69 |
+
};
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
function processMaxRssBytes(): number {
|
| 73 |
+
return Math.max(0, Math.round(process.resourceUsage().maxRSS * 1024));
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
async function waitForCondition(check: () => boolean): Promise<boolean> {
|
| 77 |
+
const deadline = Date.now() + 30_000;
|
| 78 |
+
while (Date.now() < deadline) {
|
| 79 |
+
if (check()) {
|
| 80 |
+
return true;
|
| 81 |
+
}
|
| 82 |
+
await new Promise<void>((resolve) => {
|
| 83 |
+
setImmediate(resolve);
|
| 84 |
+
});
|
| 85 |
+
}
|
| 86 |
+
return check();
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
async function drainSpawnSampleActiveWork(
|
| 90 |
+
waitForActiveWork?: (
|
| 91 |
+
timeoutMs: number,
|
| 92 |
+
) => Promise<{ drained: boolean; snapshot: { counts: { totalActive: number } } }>,
|
| 93 |
+
): Promise<void> {
|
| 94 |
+
const wait =
|
| 95 |
+
waitForActiveWork ??
|
| 96 |
+
(await import("../src/infra/gateway-active-work.js")).waitForGatewayActiveWork;
|
| 97 |
+
const result = await wait(30_000);
|
| 98 |
+
const active = result.snapshot.counts.totalActive;
|
| 99 |
+
if (!result.drained || active !== 0) {
|
| 100 |
+
throw new Error(`spawn sample left ${active} active gateway work items`);
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
async function resetRuntime(persist: boolean): Promise<void> {
|
| 105 |
+
const [subagents, tasks, stateDb, agentDb] = await Promise.all([
|
| 106 |
+
import("../src/agents/subagents/registry/subagent-registry.test-helpers.js"),
|
| 107 |
+
import("../src/tasks/task-runtime.test-helpers.js"),
|
| 108 |
+
import("../src/state/openclaw-state-db.js"),
|
| 109 |
+
import("../src/state/openclaw-agent-db.js"),
|
| 110 |
+
]);
|
| 111 |
+
subagents.resetSubagentRegistryForTests({ persist });
|
| 112 |
+
subagents.testing.setDepsForTest();
|
| 113 |
+
tasks.resetTaskRegistryControlRuntimeForTests();
|
| 114 |
+
tasks.resetTaskRegistryDeliveryRuntimeForTests();
|
| 115 |
+
tasks.resetDetachedTaskLifecycleRuntimeForTests();
|
| 116 |
+
tasks.resetTaskRegistryForTests({ persist });
|
| 117 |
+
tasks.resetTaskFlowRegistryForTests({ persist });
|
| 118 |
+
stateDb.closeOpenClawStateDatabaseForTest();
|
| 119 |
+
agentDb.closeOpenClawAgentDatabasesForTest();
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
function createTerminalWaitBarrier() {
|
| 123 |
+
const waiters = new Map<string, (value: unknown) => void>();
|
| 124 |
+
let outstanding = 0;
|
| 125 |
+
let releasesStarted = false;
|
| 126 |
+
const callGateway = (async <T>(request: { method?: string; params?: unknown }) => {
|
| 127 |
+
if (request.method !== "agent.wait") {
|
| 128 |
+
return {} as T;
|
| 129 |
+
}
|
| 130 |
+
if (releasesStarted) {
|
| 131 |
+
throw new Error("agent.wait registered after the benchmark barrier released");
|
| 132 |
+
}
|
| 133 |
+
const runId =
|
| 134 |
+
request.params &&
|
| 135 |
+
typeof request.params === "object" &&
|
| 136 |
+
!Array.isArray(request.params) &&
|
| 137 |
+
typeof (request.params as { runId?: unknown }).runId === "string"
|
| 138 |
+
? (request.params as { runId: string }).runId
|
| 139 |
+
: undefined;
|
| 140 |
+
if (!runId || waiters.has(runId)) {
|
| 141 |
+
throw new Error(`invalid or duplicate benchmark agent.wait run id: ${runId ?? "missing"}`);
|
| 142 |
+
}
|
| 143 |
+
outstanding += 1;
|
| 144 |
+
return await new Promise<T>((resolve) => {
|
| 145 |
+
waiters.set(runId, (value) => {
|
| 146 |
+
outstanding -= 1;
|
| 147 |
+
resolve(value as T);
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
}) as typeof import("../src/gateway/call.js").callGateway;
|
| 151 |
+
return {
|
| 152 |
+
callGateway,
|
| 153 |
+
get outstanding() {
|
| 154 |
+
return outstanding;
|
| 155 |
+
},
|
| 156 |
+
async waitUntilBlocked(expected: number) {
|
| 157 |
+
return await waitForCondition(() => outstanding === expected);
|
| 158 |
+
},
|
| 159 |
+
release(runId: string) {
|
| 160 |
+
releasesStarted = true;
|
| 161 |
+
const resolve = waiters.get(runId);
|
| 162 |
+
if (!resolve) {
|
| 163 |
+
throw new Error(`missing blocked benchmark agent.wait for ${runId}`);
|
| 164 |
+
}
|
| 165 |
+
waiters.delete(runId);
|
| 166 |
+
const endedAt = Date.now();
|
| 167 |
+
resolve({
|
| 168 |
+
status: "ok",
|
| 169 |
+
startedAt: endedAt - 1,
|
| 170 |
+
endedAt,
|
| 171 |
+
stopReason: "stop",
|
| 172 |
+
});
|
| 173 |
+
},
|
| 174 |
+
releaseAll() {
|
| 175 |
+
releasesStarted = true;
|
| 176 |
+
for (const runId of waiters.keys()) {
|
| 177 |
+
this.release(runId);
|
| 178 |
+
}
|
| 179 |
+
},
|
| 180 |
+
};
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
async function configureSpawnRuntime(
|
| 184 |
+
mode: "memory" | "durable",
|
| 185 |
+
callGateway: typeof import("../src/gateway/call.js").callGateway,
|
| 186 |
+
): Promise<void> {
|
| 187 |
+
const [subagents, registry, taskStore, flowStore] = await Promise.all([
|
| 188 |
+
import("../src/agents/subagents/registry/subagent-registry.test-helpers.js"),
|
| 189 |
+
import("../src/agents/subagents/registry/subagent-registry-memory.js"),
|
| 190 |
+
import("../src/tasks/task-registry.store.js"),
|
| 191 |
+
import("../src/tasks/task-flow-registry.store.test-support.js"),
|
| 192 |
+
]);
|
| 193 |
+
const sharedDeps = {
|
| 194 |
+
callGateway,
|
| 195 |
+
getRuntimeConfig: () => ({}),
|
| 196 |
+
onAgentEvent: () => () => {},
|
| 197 |
+
resolveAgentTimeoutMs: () => 1_000,
|
| 198 |
+
captureSubagentCompletionReply: async (childSessionKey: string) => {
|
| 199 |
+
const entry = [...registry.subagentRuns.values()].find(
|
| 200 |
+
(candidate) => candidate.childSessionKey === childSessionKey,
|
| 201 |
+
);
|
| 202 |
+
if (entry) {
|
| 203 |
+
// Completion already owns the row at this awaited seam. Suppress only
|
| 204 |
+
// its unrelated session projection, not the terminal registry/task transition.
|
| 205 |
+
entry.execution.suppressSessionEffects = true;
|
| 206 |
+
}
|
| 207 |
+
return undefined;
|
| 208 |
+
},
|
| 209 |
+
cleanupBrowserSessionsForLifecycleEnd: async () => {},
|
| 210 |
+
runSubagentAnnounceFlow: async () => "retryable" as const,
|
| 211 |
+
maybeWakeRequesterAfterAllChildrenSettled: async () => false,
|
| 212 |
+
ensureContextEnginesInitialized: () => {},
|
| 213 |
+
loadAgentRuntimePluginRegistryHandle: () => undefined,
|
| 214 |
+
resolveContextEngine: async () =>
|
| 215 |
+
({
|
| 216 |
+
info: { id: "bench", name: "bench", version: "1" },
|
| 217 |
+
ingest: async () => ({ ok: true }),
|
| 218 |
+
assemble: async () => ({ messages: [] }),
|
| 219 |
+
onSubagentEnded: async () => {},
|
| 220 |
+
}) as unknown as import("../src/context-engine/types.js").ContextEngine,
|
| 221 |
+
};
|
| 222 |
+
if (mode === "memory") {
|
| 223 |
+
subagents.testing.setDepsForTest({
|
| 224 |
+
...sharedDeps,
|
| 225 |
+
persistSubagentRunsToDisk: () => {},
|
| 226 |
+
persistSubagentRunsToDiskOrThrow: () => {},
|
| 227 |
+
});
|
| 228 |
+
taskStore.configureTaskRegistryRuntime({
|
| 229 |
+
store: {
|
| 230 |
+
loadSnapshot: () => ({ tasks: new Map(), deliveryStates: new Map() }),
|
| 231 |
+
upsertTaskWithDeliveryState: () => {},
|
| 232 |
+
deleteTaskWithDeliveryState: () => {},
|
| 233 |
+
upsertDeliveryState: () => {},
|
| 234 |
+
close: () => {},
|
| 235 |
+
},
|
| 236 |
+
});
|
| 237 |
+
const { createInMemoryTaskFlowRegistryStore } =
|
| 238 |
+
await import("../src/test-utils/task-registry-store.js");
|
| 239 |
+
flowStore.configureTaskFlowRegistryRuntime({
|
| 240 |
+
store: createInMemoryTaskFlowRegistryStore(),
|
| 241 |
+
});
|
| 242 |
+
return;
|
| 243 |
+
}
|
| 244 |
+
subagents.testing.setDepsForTest(sharedDeps);
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
type BenchmarkStateDatabase = Pick<OpenClawStateKyselyDatabase, "subagent_runs" | "task_runs">;
|
| 248 |
+
|
| 249 |
+
async function readDurableRows() {
|
| 250 |
+
const [{ executeSqliteQuerySync, getNodeSqliteKysely }, stateDb] = await Promise.all([
|
| 251 |
+
import("../src/infra/kysely-sync.js"),
|
| 252 |
+
import("../src/state/openclaw-state-db.js"),
|
| 253 |
+
]);
|
| 254 |
+
const database = stateDb.openOpenClawStateDatabase();
|
| 255 |
+
const db = getNodeSqliteKysely<BenchmarkStateDatabase>(database.db);
|
| 256 |
+
return {
|
| 257 |
+
path: database.path,
|
| 258 |
+
subagentRows: executeSqliteQuerySync(
|
| 259 |
+
database.db,
|
| 260 |
+
db
|
| 261 |
+
.selectFrom("subagent_runs")
|
| 262 |
+
.select((eb) => [
|
| 263 |
+
"run_id",
|
| 264 |
+
eb
|
| 265 |
+
.fn<number | null>("json_extract", ["payload_json", eb.val("$.execution.endedAt")])
|
| 266 |
+
.as("ended_at"),
|
| 267 |
+
])
|
| 268 |
+
.orderBy("run_id"),
|
| 269 |
+
).rows,
|
| 270 |
+
taskRows: executeSqliteQuerySync(
|
| 271 |
+
database.db,
|
| 272 |
+
db
|
| 273 |
+
.selectFrom("task_runs")
|
| 274 |
+
.select(["run_id", "status", "ended_at"])
|
| 275 |
+
.where("runtime", "=", "subagent")
|
| 276 |
+
.orderBy("run_id"),
|
| 277 |
+
).rows,
|
| 278 |
+
};
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function listSqliteFiles(root: string): string[] {
|
| 282 |
+
return fs
|
| 283 |
+
.readdirSync(root, { recursive: true })
|
| 284 |
+
.map(String)
|
| 285 |
+
.filter((entry) => entry.includes(".sqlite"))
|
| 286 |
+
.toSorted();
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
async function listBenchmarkTasks() {
|
| 290 |
+
const tasks = await import("../src/tasks/task-registry.js");
|
| 291 |
+
return tasks.listTaskRecords().filter((task) => task.runtime === "subagent");
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
async function listBenchmarkTaskMemory() {
|
| 295 |
+
const state = await import("../src/tasks/task-registry-state.js");
|
| 296 |
+
return [...state.tasks.values()].filter((task) => task.runtime === "subagent");
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
function assertExactRunIds(actual: Array<string | null>, expected: string[], label: string): void {
|
| 300 |
+
const normalized = actual
|
| 301 |
+
.filter((value): value is string => typeof value === "string")
|
| 302 |
+
.toSorted();
|
| 303 |
+
const wanted = expected.toSorted();
|
| 304 |
+
if (JSON.stringify(normalized) !== JSON.stringify(wanted)) {
|
| 305 |
+
throw new Error(
|
| 306 |
+
`${label} mismatch: ${JSON.stringify({ actual: normalized, expected: wanted })}`,
|
| 307 |
+
);
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
async function runSpawnSample(
|
| 312 |
+
fanout: number,
|
| 313 |
+
serial: number,
|
| 314 |
+
mode: "memory" | "durable",
|
| 315 |
+
stateDir: string,
|
| 316 |
+
): Promise<Sample> {
|
| 317 |
+
const [pipeline, registry] = await Promise.all([
|
| 318 |
+
import("../src/agents/spawn-pipeline.js"),
|
| 319 |
+
import("../src/agents/subagents/registry/subagent-registry-memory.js"),
|
| 320 |
+
]);
|
| 321 |
+
await resetRuntime(mode === "durable");
|
| 322 |
+
const barrier = createTerminalWaitBarrier();
|
| 323 |
+
await configureSpawnRuntime(mode, barrier.callGateway);
|
| 324 |
+
const taskRegistry = await import("../src/tasks/task-registry.js");
|
| 325 |
+
let releases = 0;
|
| 326 |
+
const runIds: string[] = [];
|
| 327 |
+
const params = Array.from({ length: fanout }, (_, index) => {
|
| 328 |
+
const runId = `bench-${mode}-${serial}-${index}`;
|
| 329 |
+
runIds.push(runId);
|
| 330 |
+
let released = false;
|
| 331 |
+
return {
|
| 332 |
+
adapter: {
|
| 333 |
+
initialize: async () => ({ index }),
|
| 334 |
+
dispatchTurn: async () => ({ runId }),
|
| 335 |
+
cleanupOnFailure: async () => {},
|
| 336 |
+
},
|
| 337 |
+
admissionReservation: {
|
| 338 |
+
release: () => {
|
| 339 |
+
if (!released) {
|
| 340 |
+
released = true;
|
| 341 |
+
releases += 1;
|
| 342 |
+
}
|
| 343 |
+
},
|
| 344 |
+
},
|
| 345 |
+
buildRegistration: () => ({
|
| 346 |
+
runId,
|
| 347 |
+
childSessionKey: `agent:bench:subagent:${mode}:${serial}:${index}`,
|
| 348 |
+
requesterSessionKey: "agent:bench:main",
|
| 349 |
+
requesterDisplayKey: "bench",
|
| 350 |
+
task: `benchmark child ${index}`,
|
| 351 |
+
cleanup: "keep" as const,
|
| 352 |
+
expectsCompletionMessage: false,
|
| 353 |
+
}),
|
| 354 |
+
progressSessionKey: "agent:bench:main",
|
| 355 |
+
};
|
| 356 |
+
});
|
| 357 |
+
let result: Sample | undefined;
|
| 358 |
+
let failure: unknown;
|
| 359 |
+
try {
|
| 360 |
+
const startedAt = performance.now();
|
| 361 |
+
const pipelineResults = await Promise.all(
|
| 362 |
+
params.map((entry) => pipeline.runSpawnPipeline(entry)),
|
| 363 |
+
);
|
| 364 |
+
const durationMs = performance.now() - startedAt;
|
| 365 |
+
const blocked = await barrier.waitUntilBlocked(fanout);
|
| 366 |
+
const successful = pipelineResults.filter((pipelineResult) => pipelineResult.ok);
|
| 367 |
+
const uniqueRuns = new Set(successful.map((pipelineResult) => pipelineResult.runId)).size;
|
| 368 |
+
const registeredRuns = registry.subagentRuns.size;
|
| 369 |
+
const tasksWhileBlocked = await listBenchmarkTasks();
|
| 370 |
+
assertExactRunIds(
|
| 371 |
+
tasksWhileBlocked.map((task) => task.runId ?? null),
|
| 372 |
+
runIds,
|
| 373 |
+
`${mode} in-memory task rows`,
|
| 374 |
+
);
|
| 375 |
+
if (!blocked || barrier.outstanding !== fanout) {
|
| 376 |
+
throw new Error(`spawn ${mode} did not block ${fanout} agent.wait calls`);
|
| 377 |
+
}
|
| 378 |
+
if (
|
| 379 |
+
successful.length !== fanout ||
|
| 380 |
+
uniqueRuns !== fanout ||
|
| 381 |
+
registeredRuns !== fanout ||
|
| 382 |
+
releases !== fanout
|
| 383 |
+
) {
|
| 384 |
+
throw new Error(
|
| 385 |
+
`spawn ${mode} registration invariant failed: ${JSON.stringify({ fanout, uniqueRuns, registeredRuns, releases })}`,
|
| 386 |
+
);
|
| 387 |
+
}
|
| 388 |
+
let durableSubagentRows = 0;
|
| 389 |
+
let durableTaskRows = 0;
|
| 390 |
+
let durableStateFile = listSqliteFiles(stateDir).length > 0;
|
| 391 |
+
if (mode === "durable") {
|
| 392 |
+
const durable = await readDurableRows();
|
| 393 |
+
assertExactRunIds(
|
| 394 |
+
durable.subagentRows.map((row) => row.run_id),
|
| 395 |
+
runIds,
|
| 396 |
+
"durable subagent rows",
|
| 397 |
+
);
|
| 398 |
+
assertExactRunIds(
|
| 399 |
+
durable.taskRows.map((row) => row.run_id),
|
| 400 |
+
runIds,
|
| 401 |
+
"durable task rows",
|
| 402 |
+
);
|
| 403 |
+
if (
|
| 404 |
+
durable.subagentRows.some((row) => row.ended_at !== null) ||
|
| 405 |
+
durable.taskRows.some((row) => row.status !== "running" || row.ended_at !== null)
|
| 406 |
+
) {
|
| 407 |
+
throw new Error("durable rows settled before the benchmark barrier released");
|
| 408 |
+
}
|
| 409 |
+
durableSubagentRows = durable.subagentRows.length;
|
| 410 |
+
durableTaskRows = durable.taskRows.length;
|
| 411 |
+
durableStateFile = fs.existsSync(durable.path);
|
| 412 |
+
if (!durableStateFile) {
|
| 413 |
+
throw new Error(`durable spawn pipeline database is missing at ${durable.path}`);
|
| 414 |
+
}
|
| 415 |
+
} else if (durableStateFile) {
|
| 416 |
+
throw new Error("in-memory spawn pipeline created durable SQLite state");
|
| 417 |
+
}
|
| 418 |
+
for (const runId of runIds) {
|
| 419 |
+
barrier.release(runId);
|
| 420 |
+
const runSettled = await waitForCondition(() => {
|
| 421 |
+
const run = registry.subagentRuns.get(runId);
|
| 422 |
+
const task = taskRegistry.findTaskByRunId(runId);
|
| 423 |
+
return (
|
| 424 |
+
run?.execution.status === "terminal" &&
|
| 425 |
+
typeof run.execution.endedAt === "number" &&
|
| 426 |
+
typeof run.cleanupCompletedAt === "number" &&
|
| 427 |
+
task?.status === "succeeded"
|
| 428 |
+
);
|
| 429 |
+
});
|
| 430 |
+
if (!runSettled) {
|
| 431 |
+
throw new Error(`spawn ${mode} did not settle released run ${runId}`);
|
| 432 |
+
}
|
| 433 |
+
}
|
| 434 |
+
const settledTasks = await listBenchmarkTasks();
|
| 435 |
+
const settledRuns = [...registry.subagentRuns.values()].filter(
|
| 436 |
+
(entry) =>
|
| 437 |
+
entry.execution.status === "terminal" &&
|
| 438 |
+
typeof entry.execution.endedAt === "number" &&
|
| 439 |
+
typeof entry.cleanupCompletedAt === "number",
|
| 440 |
+
).length;
|
| 441 |
+
const succeededTasks = settledTasks.filter((task) => task.status === "succeeded").length;
|
| 442 |
+
if (settledRuns !== fanout || succeededTasks !== fanout || barrier.outstanding !== 0) {
|
| 443 |
+
throw new Error(
|
| 444 |
+
`spawn ${mode} settlement invariant failed: ${JSON.stringify({ fanout, settledRuns, succeededTasks, outstandingWaits: barrier.outstanding })}`,
|
| 445 |
+
);
|
| 446 |
+
}
|
| 447 |
+
// Terminal rows can settle before detached cleanup and requester-wake roots.
|
| 448 |
+
// Drain before reset so leaked work stays visible and cannot reach the next sample.
|
| 449 |
+
await drainSpawnSampleActiveWork();
|
| 450 |
+
result = {
|
| 451 |
+
durationMs,
|
| 452 |
+
invariant: {
|
| 453 |
+
ok: true,
|
| 454 |
+
registeredRuns,
|
| 455 |
+
reservationsReleased: releases,
|
| 456 |
+
blockedWaits: fanout,
|
| 457 |
+
settledRuns,
|
| 458 |
+
settledTasks: succeededTasks,
|
| 459 |
+
outstandingWaits: barrier.outstanding,
|
| 460 |
+
durableSubagentRows,
|
| 461 |
+
durableTaskRows,
|
| 462 |
+
durableStateFile,
|
| 463 |
+
postTeardownRegistryRows: -1,
|
| 464 |
+
postTeardownTaskRows: -1,
|
| 465 |
+
postTeardownDurableSubagentRows: -1,
|
| 466 |
+
postTeardownDurableTaskRows: -1,
|
| 467 |
+
postTeardownActiveRootWork: 0,
|
| 468 |
+
},
|
| 469 |
+
};
|
| 470 |
+
} catch (error) {
|
| 471 |
+
failure = error;
|
| 472 |
+
} finally {
|
| 473 |
+
try {
|
| 474 |
+
barrier.releaseAll();
|
| 475 |
+
} catch (error) {
|
| 476 |
+
failure ??= error;
|
| 477 |
+
}
|
| 478 |
+
try {
|
| 479 |
+
await resetRuntime(mode === "durable");
|
| 480 |
+
} catch (error) {
|
| 481 |
+
failure ??= error;
|
| 482 |
+
}
|
| 483 |
+
}
|
| 484 |
+
if (failure) {
|
| 485 |
+
throw toErrorObject(failure, "Agent concurrency benchmark failed");
|
| 486 |
+
}
|
| 487 |
+
const postTeardownTasks = await listBenchmarkTaskMemory();
|
| 488 |
+
const postTeardownRegistryRows = registry.subagentRuns.size;
|
| 489 |
+
const postTeardownTaskRows = postTeardownTasks.length;
|
| 490 |
+
let postTeardownDurableSubagentRows = 0;
|
| 491 |
+
let postTeardownDurableTaskRows = 0;
|
| 492 |
+
if (mode === "durable") {
|
| 493 |
+
const durable = await readDurableRows();
|
| 494 |
+
postTeardownDurableSubagentRows = durable.subagentRows.length;
|
| 495 |
+
postTeardownDurableTaskRows = durable.taskRows.length;
|
| 496 |
+
await resetRuntime(false);
|
| 497 |
+
}
|
| 498 |
+
if (
|
| 499 |
+
postTeardownRegistryRows !== 0 ||
|
| 500 |
+
postTeardownTaskRows !== 0 ||
|
| 501 |
+
postTeardownDurableSubagentRows !== 0 ||
|
| 502 |
+
postTeardownDurableTaskRows !== 0 ||
|
| 503 |
+
barrier.outstanding !== 0
|
| 504 |
+
) {
|
| 505 |
+
throw new Error(
|
| 506 |
+
`spawn ${mode} teardown invariant failed: ${JSON.stringify({ postTeardownRegistryRows, postTeardownTaskRows, postTeardownDurableSubagentRows, postTeardownDurableTaskRows, outstandingWaits: barrier.outstanding })}`,
|
| 507 |
+
);
|
| 508 |
+
}
|
| 509 |
+
if (mode === "memory" && listSqliteFiles(stateDir).length > 0) {
|
| 510 |
+
throw new Error("in-memory spawn pipeline created SQLite state during teardown");
|
| 511 |
+
}
|
| 512 |
+
if (!result) {
|
| 513 |
+
throw new Error(`spawn ${mode} did not produce a benchmark result`);
|
| 514 |
+
}
|
| 515 |
+
result.invariant.postTeardownRegistryRows = postTeardownRegistryRows;
|
| 516 |
+
result.invariant.postTeardownTaskRows = postTeardownTaskRows;
|
| 517 |
+
result.invariant.postTeardownDurableSubagentRows = postTeardownDurableSubagentRows;
|
| 518 |
+
result.invariant.postTeardownDurableTaskRows = postTeardownDurableTaskRows;
|
| 519 |
+
return result;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
async function runAdmissionSample(fanout: number, serial: number): Promise<Sample> {
|
| 523 |
+
const { reserveChildAdmissionSlot } = await import("../src/agents/child-admission.js");
|
| 524 |
+
const controllerSessionKey = `agent:bench:admission:${serial}`;
|
| 525 |
+
const reservations: Array<{ release: () => void }> = [];
|
| 526 |
+
const reserve = () =>
|
| 527 |
+
reserveChildAdmissionSlot({
|
| 528 |
+
controllerSessionKey,
|
| 529 |
+
resolveAdmission: (pendingChildren) =>
|
| 530 |
+
pendingChildren < fanout
|
| 531 |
+
? { ok: true as const }
|
| 532 |
+
: { ok: false as const, governingCap: "benchmark" },
|
| 533 |
+
});
|
| 534 |
+
const startedAt = performance.now();
|
| 535 |
+
for (let index = 0; index < fanout; index += 1) {
|
| 536 |
+
const reservation = reserve();
|
| 537 |
+
if (!reservation.ok) {
|
| 538 |
+
throw new Error(`admission rejected slot ${index + 1}/${fanout}`);
|
| 539 |
+
}
|
| 540 |
+
reservations.push(reservation);
|
| 541 |
+
}
|
| 542 |
+
const overflow = reserve();
|
| 543 |
+
for (const reservation of reservations) {
|
| 544 |
+
reservation.release();
|
| 545 |
+
reservation.release();
|
| 546 |
+
}
|
| 547 |
+
const replacement = reserve();
|
| 548 |
+
if (replacement.ok) {
|
| 549 |
+
replacement.release();
|
| 550 |
+
}
|
| 551 |
+
const durationMs = performance.now() - startedAt;
|
| 552 |
+
const ok = !overflow.ok && replacement.ok;
|
| 553 |
+
if (!ok) {
|
| 554 |
+
throw new Error(`admission invariant failed at fanout ${fanout}`);
|
| 555 |
+
}
|
| 556 |
+
return {
|
| 557 |
+
durationMs,
|
| 558 |
+
invariant: { ok, admissionCap: reservations.length, overflowRejected: true, released: true },
|
| 559 |
+
};
|
| 560 |
+
}
|
| 561 |
+
|
| 562 |
+
function sweepRow(child: number, generation: number, now: number): SubagentRunRecord {
|
| 563 |
+
const current = generation === 3;
|
| 564 |
+
return {
|
| 565 |
+
runId: `bench-sweep-${child}-${generation}`,
|
| 566 |
+
childSessionKey: `agent:bench:subagent:sweep-${child}`,
|
| 567 |
+
requesterSessionKey: "agent:bench:main",
|
| 568 |
+
requesterDisplayKey: "bench",
|
| 569 |
+
task: `sweep child ${child}`,
|
| 570 |
+
cleanup: current ? "keep" : "delete",
|
| 571 |
+
generation,
|
| 572 |
+
createdAt: now - generation,
|
| 573 |
+
archiveAtMs: current ? undefined : now - 1,
|
| 574 |
+
terminalOwner: current ? "interrupted-recovery" : undefined,
|
| 575 |
+
endedReason: current ? "subagent-error" : undefined,
|
| 576 |
+
execution: current
|
| 577 |
+
? {
|
| 578 |
+
status: "terminal",
|
| 579 |
+
startedAt: now - 2_000,
|
| 580 |
+
endedAt: now - 1_000,
|
| 581 |
+
outcome: { status: "error", error: "interrupted recovery replay" },
|
| 582 |
+
suppressSessionEffects: true,
|
| 583 |
+
}
|
| 584 |
+
: {
|
| 585 |
+
status: "terminal",
|
| 586 |
+
startedAt: now - 2_000,
|
| 587 |
+
endedAt: now - 1_000,
|
| 588 |
+
outcome: { status: "error", error: "retired recovery generation" },
|
| 589 |
+
suppressSessionEffects: true,
|
| 590 |
+
},
|
| 591 |
+
};
|
| 592 |
+
}
|
| 593 |
+
|
| 594 |
+
async function runSweepSample(childCount: number): Promise<Sample> {
|
| 595 |
+
const [
|
| 596 |
+
{ getSubagentRunsForChildSession, subagentRuns: runs },
|
| 597 |
+
{ createSubagentRegistrySweeper },
|
| 598 |
+
] = await Promise.all([
|
| 599 |
+
import("../src/agents/subagents/registry/subagent-registry-memory.js"),
|
| 600 |
+
import("../src/agents/subagents/registry/subagent-registry-sweeper.js"),
|
| 601 |
+
]);
|
| 602 |
+
const now = Date.now();
|
| 603 |
+
runs.clear();
|
| 604 |
+
for (let child = 0; child < childCount; child += 1) {
|
| 605 |
+
for (const generation of [3, 2, 1]) {
|
| 606 |
+
const entry = sweepRow(child, generation, now);
|
| 607 |
+
runs.set(entry.runId, entry);
|
| 608 |
+
}
|
| 609 |
+
}
|
| 610 |
+
let sessionEffects = 0;
|
| 611 |
+
let recoveryProjections = 0;
|
| 612 |
+
let lostContextCompletions = 0;
|
| 613 |
+
const sweeper = createSubagentRegistrySweeper({
|
| 614 |
+
runs,
|
| 615 |
+
resumedRuns: new Set(),
|
| 616 |
+
persist: () => {},
|
| 617 |
+
clearPendingLifecycleError: () => {},
|
| 618 |
+
clearPendingLifecycleTimeout: () => {},
|
| 619 |
+
clearPendingSubagentRecoveryNotice: () => true,
|
| 620 |
+
sweepPendingLifecycle: () => {},
|
| 621 |
+
completeSubagentRunWithRecovery: async () => {
|
| 622 |
+
lostContextCompletions += 1;
|
| 623 |
+
},
|
| 624 |
+
getGatewayRecoveryRuntime: () => undefined,
|
| 625 |
+
abandonSubagentRestartRecoveryLaunch: () => true,
|
| 626 |
+
clearAcceptedSubagentRestartRecovery: () => true,
|
| 627 |
+
resumeSettledSubagentRestartRecovery: () => true,
|
| 628 |
+
replaceSubagentRunAfterSteer: () => true,
|
| 629 |
+
markSubagentRestartRecoveryLaunchAttempted: () => undefined,
|
| 630 |
+
markSubagentRestartRecoveryLaunchAccepted: () => undefined,
|
| 631 |
+
markSubagentRestartRecoveryLaunchConsumed: () => undefined,
|
| 632 |
+
reserveSubagentRestartRecoveryLaunch: () => undefined,
|
| 633 |
+
resetSubagentRestartRecoveryLaunchAttempt: () => true,
|
| 634 |
+
finalizeInterruptedSubagentRun: async ({ runId, expectedEntry }) => {
|
| 635 |
+
if (runs.get(runId) !== expectedEntry || expectedEntry?.generation !== 3) {
|
| 636 |
+
throw new Error(`unexpected recovery projection owner: ${runId}`);
|
| 637 |
+
}
|
| 638 |
+
recoveryProjections += 1;
|
| 639 |
+
return 1;
|
| 640 |
+
},
|
| 641 |
+
resumeRequesterSettleWake: () => {},
|
| 642 |
+
startSubagentAnnounceCleanupFlow: () => true,
|
| 643 |
+
completeCleanupBookkeeping: () => {},
|
| 644 |
+
discardTerminalDelivery: () => {},
|
| 645 |
+
shouldEmitEndedHookForRun: () => false,
|
| 646 |
+
emitSubagentEndedHookForRun: async () => {},
|
| 647 |
+
callGateway: (async <T>() => {
|
| 648 |
+
sessionEffects += 1;
|
| 649 |
+
return {} as T;
|
| 650 |
+
}) as typeof import("../src/gateway/call.js").callGateway,
|
| 651 |
+
cleanupCollectorLaunchResources: async () => true,
|
| 652 |
+
runContextEngineSubagentEnded: async () => {
|
| 653 |
+
sessionEffects += 1;
|
| 654 |
+
},
|
| 655 |
+
notifyContextEngineSubagentEnded: async () => {
|
| 656 |
+
sessionEffects += 1;
|
| 657 |
+
},
|
| 658 |
+
retireSupersededRun: async () => {},
|
| 659 |
+
getRunsForChildSession: getSubagentRunsForChildSession,
|
| 660 |
+
getRunsForCollectorGroup: () => [],
|
| 661 |
+
warn: () => {},
|
| 662 |
+
});
|
| 663 |
+
try {
|
| 664 |
+
const startedAt = performance.now();
|
| 665 |
+
await sweeper.sweepOnce();
|
| 666 |
+
const durationMs = performance.now() - startedAt;
|
| 667 |
+
const retainedCurrent = [...runs.values()].filter((entry) => entry.generation === 3).length;
|
| 668 |
+
const removedRows = childCount * 3 - runs.size;
|
| 669 |
+
const ok =
|
| 670 |
+
removedRows === childCount * 2 &&
|
| 671 |
+
retainedCurrent === childCount &&
|
| 672 |
+
sessionEffects === 0 &&
|
| 673 |
+
recoveryProjections === childCount &&
|
| 674 |
+
lostContextCompletions === 0;
|
| 675 |
+
if (!ok) {
|
| 676 |
+
throw new Error(
|
| 677 |
+
`registry sweep invariant failed: ${JSON.stringify({ childCount, removedRows, retainedCurrent, sessionEffects, recoveryProjections, lostContextCompletions })}`,
|
| 678 |
+
);
|
| 679 |
+
}
|
| 680 |
+
return {
|
| 681 |
+
durationMs,
|
| 682 |
+
invariant: {
|
| 683 |
+
ok,
|
| 684 |
+
seededRows: childCount * 3,
|
| 685 |
+
removedRows,
|
| 686 |
+
retainedCurrent,
|
| 687 |
+
sessionEffects,
|
| 688 |
+
recoveryProjections,
|
| 689 |
+
lostContextCompletions,
|
| 690 |
+
},
|
| 691 |
+
};
|
| 692 |
+
} finally {
|
| 693 |
+
sweeper.reset();
|
| 694 |
+
runs.clear();
|
| 695 |
+
}
|
| 696 |
+
}
|
| 697 |
+
|
| 698 |
+
async function runDedupeSample(childCount: number): Promise<Sample> {
|
| 699 |
+
const { dedupeLatestChildCompletionRows } =
|
| 700 |
+
await import("../src/agents/subagents/announce/subagent-announce-output.js");
|
| 701 |
+
const rowsForOrder = (generations: number[]) =>
|
| 702 |
+
Array.from({ length: childCount }, (_, child) =>
|
| 703 |
+
generations.map((generation) => ({
|
| 704 |
+
runId: `bench-dedupe-${child}-${generation}`,
|
| 705 |
+
childSessionKey: `agent:bench:subagent:dedupe-${child}`,
|
| 706 |
+
task: `dedupe child ${child}`,
|
| 707 |
+
generation,
|
| 708 |
+
createdAt: generation,
|
| 709 |
+
execution: { status: "terminal" as const, endedAt: generation },
|
| 710 |
+
})),
|
| 711 |
+
).flat();
|
| 712 |
+
const newestFirstRows = rowsForOrder([3, 2, 1]);
|
| 713 |
+
const oldestFirstRows = rowsForOrder([1, 2, 3]);
|
| 714 |
+
const startedAt = performance.now();
|
| 715 |
+
const newestFirst = dedupeLatestChildCompletionRows(newestFirstRows);
|
| 716 |
+
const oldestFirst = dedupeLatestChildCompletionRows(oldestFirstRows);
|
| 717 |
+
const durationMs = performance.now() - startedAt;
|
| 718 |
+
const newestFirstSelectedNewest = newestFirst.every((row) => row.generation === 3);
|
| 719 |
+
const oldestFirstSelectedNewest = oldestFirst.every((row) => row.generation === 3);
|
| 720 |
+
const ok =
|
| 721 |
+
newestFirst.length === childCount &&
|
| 722 |
+
oldestFirst.length === childCount &&
|
| 723 |
+
newestFirstSelectedNewest &&
|
| 724 |
+
oldestFirstSelectedNewest;
|
| 725 |
+
if (!ok) {
|
| 726 |
+
throw new Error(`completion dedupe invariant failed for ${childCount} children`);
|
| 727 |
+
}
|
| 728 |
+
return {
|
| 729 |
+
durationMs,
|
| 730 |
+
invariant: {
|
| 731 |
+
ok,
|
| 732 |
+
inputRowsPerOrdering: newestFirstRows.length,
|
| 733 |
+
newestFirstSelectedRows: newestFirst.length,
|
| 734 |
+
oldestFirstSelectedRows: oldestFirst.length,
|
| 735 |
+
newestFirstSelectedNewest,
|
| 736 |
+
oldestFirstSelectedNewest,
|
| 737 |
+
},
|
| 738 |
+
};
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
async function runScenario(options: WorkerOptions, stateDir: string): Promise<WorkerResult> {
|
| 742 |
+
const timingsMs: number[] = [];
|
| 743 |
+
let invariant: Record<string, number | boolean> = {};
|
| 744 |
+
const rssStartBytes = process.memoryUsage().rss;
|
| 745 |
+
for (let index = 0; index < options.warmup + options.runs; index += 1) {
|
| 746 |
+
let sample: Sample;
|
| 747 |
+
if (options.scenario === "spawnPipelineInMemory") {
|
| 748 |
+
sample = await runSpawnSample(options.size, index, "memory", stateDir);
|
| 749 |
+
} else if (options.scenario === "spawnPipelineDurable") {
|
| 750 |
+
sample = await runSpawnSample(options.size, index, "durable", stateDir);
|
| 751 |
+
} else if (options.scenario === "admission") {
|
| 752 |
+
sample = await runAdmissionSample(options.size, index);
|
| 753 |
+
} else if (options.scenario === "recoverySweep") {
|
| 754 |
+
sample = await runSweepSample(options.size);
|
| 755 |
+
} else {
|
| 756 |
+
sample = await runDedupeSample(options.size);
|
| 757 |
+
}
|
| 758 |
+
invariant = sample.invariant;
|
| 759 |
+
if (index >= options.warmup) {
|
| 760 |
+
timingsMs.push(sample.durationMs);
|
| 761 |
+
}
|
| 762 |
+
}
|
| 763 |
+
return {
|
| 764 |
+
scenario: options.scenario,
|
| 765 |
+
size: options.size,
|
| 766 |
+
timingsMs,
|
| 767 |
+
memory: {
|
| 768 |
+
rssStartBytes,
|
| 769 |
+
rssEndBytes: process.memoryUsage().rss,
|
| 770 |
+
processMaxRssBytes: processMaxRssBytes(),
|
| 771 |
+
},
|
| 772 |
+
invariant,
|
| 773 |
+
};
|
| 774 |
+
}
|
| 775 |
+
|
| 776 |
+
async function main(): Promise<void> {
|
| 777 |
+
const options = parseOptions(process.argv.slice(2));
|
| 778 |
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-concurrency-worker-"));
|
| 779 |
+
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
| 780 |
+
const previousNodeEnv = process.env.NODE_ENV;
|
| 781 |
+
let result: WorkerResult | undefined;
|
| 782 |
+
let failure: unknown;
|
| 783 |
+
process.env.OPENCLAW_STATE_DIR = stateDir;
|
| 784 |
+
process.env.NODE_ENV = "test";
|
| 785 |
+
try {
|
| 786 |
+
const { pinRuntimePaths } = await import("../src/config/paths.js");
|
| 787 |
+
pinRuntimePaths();
|
| 788 |
+
result = await runScenario(options, stateDir);
|
| 789 |
+
} catch (error) {
|
| 790 |
+
failure = error;
|
| 791 |
+
} finally {
|
| 792 |
+
try {
|
| 793 |
+
await resetRuntime(false);
|
| 794 |
+
} catch (error) {
|
| 795 |
+
failure ??= error;
|
| 796 |
+
} finally {
|
| 797 |
+
if (previousStateDir === undefined) {
|
| 798 |
+
delete process.env.OPENCLAW_STATE_DIR;
|
| 799 |
+
} else {
|
| 800 |
+
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
| 801 |
+
}
|
| 802 |
+
if (previousNodeEnv === undefined) {
|
| 803 |
+
delete process.env.NODE_ENV;
|
| 804 |
+
} else {
|
| 805 |
+
process.env.NODE_ENV = previousNodeEnv;
|
| 806 |
+
}
|
| 807 |
+
try {
|
| 808 |
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
| 809 |
+
} catch (error) {
|
| 810 |
+
failure ??= error;
|
| 811 |
+
}
|
| 812 |
+
}
|
| 813 |
+
}
|
| 814 |
+
if (failure) {
|
| 815 |
+
throw toErrorObject(failure, "Agent concurrency benchmark failed");
|
| 816 |
+
}
|
| 817 |
+
if (!result) {
|
| 818 |
+
throw new Error("benchmark worker completed without a result");
|
| 819 |
+
}
|
| 820 |
+
process.stdout.write(`${WORKER_RESULT_SENTINEL}${JSON.stringify(result)}\n`);
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
export const testing = { drainSpawnSampleActiveWork };
|
| 824 |
+
|
| 825 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 826 |
+
try {
|
| 827 |
+
await main();
|
| 828 |
+
} catch (error) {
|
| 829 |
+
console.error(error instanceof Error ? error.stack : String(error));
|
| 830 |
+
process.exitCode = 1;
|
| 831 |
+
} finally {
|
| 832 |
+
if (process.exitCode && process.exitCode !== 0) {
|
| 833 |
+
console.error(`[bench-agent-concurrency-worker] FAILED (exit ${process.exitCode})`);
|
| 834 |
+
}
|
| 835 |
+
}
|
| 836 |
+
}
|
scripts/bench-agent-concurrency.ts
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { pathToFileURL } from "node:url";
|
| 2 |
+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
| 3 |
+
import {
|
| 4 |
+
emitBenchmarkReport,
|
| 5 |
+
parseBenchmarkInteger,
|
| 6 |
+
parseBenchmarkIntegerList,
|
| 7 |
+
parseBenchmarkOptions,
|
| 8 |
+
parseBenchmarkWorkerResult,
|
| 9 |
+
runBenchmarkEntrypoint,
|
| 10 |
+
runBenchmarkJobs,
|
| 11 |
+
runBenchmarkWorker,
|
| 12 |
+
summarizeBenchmarkTimings,
|
| 13 |
+
type BenchmarkTimingSummary,
|
| 14 |
+
type BenchmarkWorkerProcessResult,
|
| 15 |
+
} from "./lib/benchmark-harness.mts";
|
| 16 |
+
|
| 17 |
+
const DEFAULT_FANOUT = [1, 8, 32, 64];
|
| 18 |
+
const DEFAULT_SWEEP_ROWS = [32, 128, 512];
|
| 19 |
+
const WORKER_TIMEOUT_MS = 300_000;
|
| 20 |
+
export const WORKER_RESULT_SENTINEL = "[bench-agent-concurrency-result] ";
|
| 21 |
+
|
| 22 |
+
export type WorkerScenario =
|
| 23 |
+
| "spawnPipelineInMemory"
|
| 24 |
+
| "spawnPipelineDurable"
|
| 25 |
+
| "admission"
|
| 26 |
+
| "recoverySweep"
|
| 27 |
+
| "duplicateSuppression";
|
| 28 |
+
|
| 29 |
+
export type WorkerResult = {
|
| 30 |
+
scenario: WorkerScenario;
|
| 31 |
+
size: number;
|
| 32 |
+
timingsMs: number[];
|
| 33 |
+
memory: {
|
| 34 |
+
rssStartBytes: number;
|
| 35 |
+
rssEndBytes: number;
|
| 36 |
+
processMaxRssBytes: number;
|
| 37 |
+
};
|
| 38 |
+
invariant: Record<string, number | boolean>;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
type Options = {
|
| 42 |
+
runs: number;
|
| 43 |
+
warmup: number;
|
| 44 |
+
fanout: number[];
|
| 45 |
+
sweepRows: number[];
|
| 46 |
+
output?: string;
|
| 47 |
+
json: boolean;
|
| 48 |
+
help: boolean;
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
const SCENARIO_SPECS: ReadonlyArray<{
|
| 52 |
+
scenario: WorkerScenario;
|
| 53 |
+
sizes: "fanout" | "sweepRows";
|
| 54 |
+
}> = [
|
| 55 |
+
{ scenario: "spawnPipelineInMemory", sizes: "fanout" },
|
| 56 |
+
{ scenario: "spawnPipelineDurable", sizes: "fanout" },
|
| 57 |
+
{ scenario: "admission", sizes: "fanout" },
|
| 58 |
+
{ scenario: "recoverySweep", sizes: "sweepRows" },
|
| 59 |
+
{ scenario: "duplicateSuppression", sizes: "sweepRows" },
|
| 60 |
+
];
|
| 61 |
+
|
| 62 |
+
const REQUIRED_INVARIANT_FIELDS: Record<WorkerScenario, readonly string[]> = {
|
| 63 |
+
spawnPipelineInMemory: [
|
| 64 |
+
"ok",
|
| 65 |
+
"registeredRuns",
|
| 66 |
+
"reservationsReleased",
|
| 67 |
+
"blockedWaits",
|
| 68 |
+
"settledRuns",
|
| 69 |
+
"settledTasks",
|
| 70 |
+
"outstandingWaits",
|
| 71 |
+
"durableSubagentRows",
|
| 72 |
+
"durableTaskRows",
|
| 73 |
+
"durableStateFile",
|
| 74 |
+
"postTeardownRegistryRows",
|
| 75 |
+
"postTeardownTaskRows",
|
| 76 |
+
"postTeardownDurableSubagentRows",
|
| 77 |
+
"postTeardownDurableTaskRows",
|
| 78 |
+
"postTeardownActiveRootWork",
|
| 79 |
+
],
|
| 80 |
+
spawnPipelineDurable: [
|
| 81 |
+
"ok",
|
| 82 |
+
"registeredRuns",
|
| 83 |
+
"reservationsReleased",
|
| 84 |
+
"blockedWaits",
|
| 85 |
+
"settledRuns",
|
| 86 |
+
"settledTasks",
|
| 87 |
+
"outstandingWaits",
|
| 88 |
+
"durableSubagentRows",
|
| 89 |
+
"durableTaskRows",
|
| 90 |
+
"durableStateFile",
|
| 91 |
+
"postTeardownRegistryRows",
|
| 92 |
+
"postTeardownTaskRows",
|
| 93 |
+
"postTeardownDurableSubagentRows",
|
| 94 |
+
"postTeardownDurableTaskRows",
|
| 95 |
+
"postTeardownActiveRootWork",
|
| 96 |
+
],
|
| 97 |
+
admission: ["ok", "admissionCap", "overflowRejected", "released"],
|
| 98 |
+
recoverySweep: [
|
| 99 |
+
"ok",
|
| 100 |
+
"seededRows",
|
| 101 |
+
"removedRows",
|
| 102 |
+
"retainedCurrent",
|
| 103 |
+
"sessionEffects",
|
| 104 |
+
"recoveryProjections",
|
| 105 |
+
"lostContextCompletions",
|
| 106 |
+
],
|
| 107 |
+
duplicateSuppression: [
|
| 108 |
+
"ok",
|
| 109 |
+
"inputRowsPerOrdering",
|
| 110 |
+
"newestFirstSelectedRows",
|
| 111 |
+
"oldestFirstSelectedRows",
|
| 112 |
+
"newestFirstSelectedNewest",
|
| 113 |
+
"oldestFirstSelectedNewest",
|
| 114 |
+
],
|
| 115 |
+
};
|
| 116 |
+
|
| 117 |
+
type BenchmarkRuntime = {
|
| 118 |
+
runWorker?: typeof runWorker;
|
| 119 |
+
writeProgress?: (line: string) => void;
|
| 120 |
+
now?: () => number;
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
function usage(): string {
|
| 124 |
+
return `OpenClaw agent concurrency benchmark
|
| 125 |
+
|
| 126 |
+
Usage:
|
| 127 |
+
node --import tsx scripts/bench-agent-concurrency.ts [options]
|
| 128 |
+
|
| 129 |
+
Options:
|
| 130 |
+
--runs <n> Measured samples per scenario (default: 5)
|
| 131 |
+
--warmup <n> Warmup samples per scenario (default: 1)
|
| 132 |
+
--fanout <list> Comma-separated spawn/admission sizes (default: 1,8,32,64)
|
| 133 |
+
--sweep-rows <list> Comma-separated child counts, with 3 generations each (default: 32,128,512)
|
| 134 |
+
--output <path> Write the JSON report to a file
|
| 135 |
+
--json Print only the JSON report
|
| 136 |
+
--help Show this text
|
| 137 |
+
`;
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function parseOptions(argv: string[]): Options {
|
| 141 |
+
return parseBenchmarkOptions<Options>(
|
| 142 |
+
argv,
|
| 143 |
+
{
|
| 144 |
+
runs: 5,
|
| 145 |
+
warmup: 1,
|
| 146 |
+
fanout: DEFAULT_FANOUT,
|
| 147 |
+
sweepRows: DEFAULT_SWEEP_ROWS,
|
| 148 |
+
json: false,
|
| 149 |
+
help: false,
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
"--runs": (options, value) => {
|
| 153 |
+
options.runs = parseBenchmarkInteger(value, "--runs", 1, 100);
|
| 154 |
+
},
|
| 155 |
+
"--warmup": (options, value) => {
|
| 156 |
+
options.warmup = parseBenchmarkInteger(value, "--warmup", 0, 20);
|
| 157 |
+
},
|
| 158 |
+
"--fanout": (options, value) => {
|
| 159 |
+
options.fanout = parseBenchmarkIntegerList(value, "--fanout", 256);
|
| 160 |
+
},
|
| 161 |
+
"--sweep-rows": (options, value) => {
|
| 162 |
+
options.sweepRows = parseBenchmarkIntegerList(value, "--sweep-rows", 4096);
|
| 163 |
+
},
|
| 164 |
+
"--output": (options, value) => {
|
| 165 |
+
options.output = value;
|
| 166 |
+
},
|
| 167 |
+
},
|
| 168 |
+
);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
function expectedWorkerKeys(options: Options): string[] {
|
| 172 |
+
return SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
|
| 173 |
+
options[sizes].map((size) => `${scenario}:${size}`),
|
| 174 |
+
);
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function aggregateWorkerResults(
|
| 178 |
+
options: Options,
|
| 179 |
+
workers: WorkerResult[],
|
| 180 |
+
parentMemory = {
|
| 181 |
+
rssStartBytes: process.memoryUsage().rss,
|
| 182 |
+
rssEndBytes: process.memoryUsage().rss,
|
| 183 |
+
},
|
| 184 |
+
) {
|
| 185 |
+
const expected = expectedWorkerKeys(options);
|
| 186 |
+
const byKey = new Map(workers.map((worker) => [`${worker.scenario}:${worker.size}`, worker]));
|
| 187 |
+
if (byKey.size !== workers.length) {
|
| 188 |
+
throw new Error("worker results contain duplicate scenario/size pairs");
|
| 189 |
+
}
|
| 190 |
+
const missing = expected.filter((key) => !byKey.has(key));
|
| 191 |
+
const unexpected = [...byKey.keys()].filter((key) => !expected.includes(key));
|
| 192 |
+
if (missing.length > 0 || unexpected.length > 0) {
|
| 193 |
+
throw new Error(
|
| 194 |
+
`worker result mismatch: missing=${missing.join(",") || "none"} unexpected=${unexpected.join(",") || "none"}`,
|
| 195 |
+
);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
const scenarios = Object.fromEntries(
|
| 199 |
+
SCENARIO_SPECS.map(({ scenario, sizes }) => [
|
| 200 |
+
scenario,
|
| 201 |
+
options[sizes].map((size) => {
|
| 202 |
+
const worker = byKey.get(`${scenario}:${size}`);
|
| 203 |
+
if (!worker) {
|
| 204 |
+
throw new Error(`missing worker result for ${scenario}:${size}`);
|
| 205 |
+
}
|
| 206 |
+
return {
|
| 207 |
+
size,
|
| 208 |
+
timingsMs: summarizeBenchmarkTimings(worker.timingsMs),
|
| 209 |
+
memory: worker.memory,
|
| 210 |
+
invariant: worker.invariant,
|
| 211 |
+
};
|
| 212 |
+
}),
|
| 213 |
+
]),
|
| 214 |
+
) as Record<
|
| 215 |
+
WorkerScenario,
|
| 216 |
+
Array<{
|
| 217 |
+
size: number;
|
| 218 |
+
timingsMs: BenchmarkTimingSummary;
|
| 219 |
+
memory: WorkerResult["memory"];
|
| 220 |
+
invariant: WorkerResult["invariant"];
|
| 221 |
+
}>
|
| 222 |
+
>;
|
| 223 |
+
|
| 224 |
+
const checks = {
|
| 225 |
+
spawnPipelineInMemory: scenarios.spawnPipelineInMemory.every(
|
| 226 |
+
(entry) => entry.invariant.ok === true,
|
| 227 |
+
),
|
| 228 |
+
spawnPipelineDurable: scenarios.spawnPipelineDurable.every(
|
| 229 |
+
(entry) => entry.invariant.ok === true,
|
| 230 |
+
),
|
| 231 |
+
admissionCapOverflowRelease: scenarios.admission.every((entry) => entry.invariant.ok === true),
|
| 232 |
+
sweepRecoveryRowsWithoutSessionEffects: scenarios.recoverySweep.every(
|
| 233 |
+
(entry) => entry.invariant.ok === true,
|
| 234 |
+
),
|
| 235 |
+
dedupeNewestPerChild: scenarios.duplicateSuppression.every(
|
| 236 |
+
(entry) => entry.invariant.ok === true,
|
| 237 |
+
),
|
| 238 |
+
};
|
| 239 |
+
const failures = Object.entries(checks)
|
| 240 |
+
.filter(([, ok]) => !ok)
|
| 241 |
+
.map(([name]) => name);
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
schemaVersion: 2,
|
| 245 |
+
generatedAt: new Date().toISOString(),
|
| 246 |
+
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
| 247 |
+
options: {
|
| 248 |
+
runs: options.runs,
|
| 249 |
+
warmup: options.warmup,
|
| 250 |
+
fanout: options.fanout,
|
| 251 |
+
sweepRows: options.sweepRows,
|
| 252 |
+
},
|
| 253 |
+
memory: {
|
| 254 |
+
...parentMemory,
|
| 255 |
+
workerProcessMaxRssBytes: Math.max(
|
| 256 |
+
...workers.map((worker) => worker.memory.processMaxRssBytes),
|
| 257 |
+
),
|
| 258 |
+
},
|
| 259 |
+
scenarios,
|
| 260 |
+
invariants: { ok: failures.length === 0, failures, ...checks },
|
| 261 |
+
};
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
function assertFiniteNonNegative(value: unknown, field: string): asserts value is number {
|
| 265 |
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
| 266 |
+
throw new Error(`worker result field ${field} must be a finite nonnegative number`);
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
function validateWorkerResult(
|
| 271 |
+
value: unknown,
|
| 272 |
+
expected: { scenario: WorkerScenario; size: number; runs: number },
|
| 273 |
+
): WorkerResult {
|
| 274 |
+
if (!isRecord(value)) {
|
| 275 |
+
throw new Error("worker result must be an object");
|
| 276 |
+
}
|
| 277 |
+
if (value.scenario !== expected.scenario || value.size !== expected.size) {
|
| 278 |
+
throw new Error(`worker ${expected.scenario}:${expected.size} returned mismatched identity`);
|
| 279 |
+
}
|
| 280 |
+
if (!Array.isArray(value.timingsMs) || value.timingsMs.length !== expected.runs) {
|
| 281 |
+
throw new Error(
|
| 282 |
+
`worker ${expected.scenario}:${expected.size} returned ${Array.isArray(value.timingsMs) ? value.timingsMs.length : "invalid"} samples; expected ${expected.runs}`,
|
| 283 |
+
);
|
| 284 |
+
}
|
| 285 |
+
value.timingsMs.forEach((timing, index) =>
|
| 286 |
+
assertFiniteNonNegative(timing, `timingsMs[${index}]`),
|
| 287 |
+
);
|
| 288 |
+
if (!isRecord(value.memory)) {
|
| 289 |
+
throw new Error("worker result memory must be an object");
|
| 290 |
+
}
|
| 291 |
+
assertFiniteNonNegative(value.memory.rssStartBytes, "memory.rssStartBytes");
|
| 292 |
+
assertFiniteNonNegative(value.memory.rssEndBytes, "memory.rssEndBytes");
|
| 293 |
+
assertFiniteNonNegative(value.memory.processMaxRssBytes, "memory.processMaxRssBytes");
|
| 294 |
+
if (!isRecord(value.invariant)) {
|
| 295 |
+
throw new Error("worker result invariant must be an object");
|
| 296 |
+
}
|
| 297 |
+
for (const field of REQUIRED_INVARIANT_FIELDS[expected.scenario]) {
|
| 298 |
+
const invariantValue = value.invariant[field];
|
| 299 |
+
if (typeof invariantValue !== "number" && typeof invariantValue !== "boolean") {
|
| 300 |
+
throw new Error(`worker result invariant.${field} is missing or invalid`);
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
if (value.invariant.ok !== true) {
|
| 304 |
+
throw new Error(`worker ${expected.scenario}:${expected.size} reported a failed invariant`);
|
| 305 |
+
}
|
| 306 |
+
return value as WorkerResult;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
function parseWorkerProcessResult(
|
| 310 |
+
result: BenchmarkWorkerProcessResult,
|
| 311 |
+
expected: { scenario: WorkerScenario; size: number; runs: number },
|
| 312 |
+
): WorkerResult {
|
| 313 |
+
return parseBenchmarkWorkerResult({
|
| 314 |
+
result,
|
| 315 |
+
label: `${expected.scenario}:${expected.size}`,
|
| 316 |
+
sentinel: WORKER_RESULT_SENTINEL,
|
| 317 |
+
timeoutMs: WORKER_TIMEOUT_MS,
|
| 318 |
+
validate: (value) => validateWorkerResult(value, expected),
|
| 319 |
+
});
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
function runWorker(options: Options, scenario: WorkerScenario, size: number): WorkerResult {
|
| 323 |
+
return runBenchmarkWorker({
|
| 324 |
+
args: [
|
| 325 |
+
"--import",
|
| 326 |
+
"tsx",
|
| 327 |
+
"scripts/bench-agent-concurrency-worker.ts",
|
| 328 |
+
"--scenario",
|
| 329 |
+
scenario,
|
| 330 |
+
"--size",
|
| 331 |
+
String(size),
|
| 332 |
+
"--runs",
|
| 333 |
+
String(options.runs),
|
| 334 |
+
"--warmup",
|
| 335 |
+
String(options.warmup),
|
| 336 |
+
],
|
| 337 |
+
label: `${scenario}:${size}`,
|
| 338 |
+
sentinel: WORKER_RESULT_SENTINEL,
|
| 339 |
+
timeoutMs: WORKER_TIMEOUT_MS,
|
| 340 |
+
validate: (value) => validateWorkerResult(value, { scenario, size, runs: options.runs }),
|
| 341 |
+
});
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
function benchmark(options: Options, runtime: BenchmarkRuntime = {}) {
|
| 345 |
+
const rssStartBytes = process.memoryUsage().rss;
|
| 346 |
+
const jobs = SCENARIO_SPECS.flatMap(({ scenario, sizes }) =>
|
| 347 |
+
options[sizes].map((size) => ({ scenario, size })),
|
| 348 |
+
);
|
| 349 |
+
const run = runtime.runWorker ?? runWorker;
|
| 350 |
+
const workers = runBenchmarkJobs(jobs, {
|
| 351 |
+
prefix: "bench-agent-concurrency",
|
| 352 |
+
describe: ({ scenario, size }) => `scenario=${scenario} size=${size}`,
|
| 353 |
+
run: ({ scenario, size }) => run(options, scenario, size),
|
| 354 |
+
now: runtime.now,
|
| 355 |
+
writeProgress: runtime.writeProgress,
|
| 356 |
+
});
|
| 357 |
+
return aggregateWorkerResults(options, workers, {
|
| 358 |
+
rssStartBytes,
|
| 359 |
+
rssEndBytes: process.memoryUsage().rss,
|
| 360 |
+
});
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
async function main(argv = process.argv.slice(2)): Promise<void> {
|
| 364 |
+
const options = parseOptions(argv);
|
| 365 |
+
if (options.help) {
|
| 366 |
+
process.stdout.write(usage());
|
| 367 |
+
return;
|
| 368 |
+
}
|
| 369 |
+
const report = benchmark(options);
|
| 370 |
+
emitBenchmarkReport(report, options, (result) => [
|
| 371 |
+
...Object.entries(result.scenarios).flatMap(([name, scenarios]) =>
|
| 372 |
+
scenarios.map((scenario) => {
|
| 373 |
+
const tail =
|
| 374 |
+
scenario.timingsMs.p95 === undefined
|
| 375 |
+
? ""
|
| 376 |
+
: ` p95=${scenario.timingsMs.p95.toFixed(3)}ms p99=${scenario.timingsMs.p99?.toFixed(3)}ms`;
|
| 377 |
+
return `${name} size=${scenario.size} p50=${scenario.timingsMs.p50.toFixed(3)}ms max=${scenario.timingsMs.max.toFixed(3)}ms${tail}`;
|
| 378 |
+
}),
|
| 379 |
+
),
|
| 380 |
+
`max worker RSS ${(result.memory.workerProcessMaxRssBytes / 1024 / 1024).toFixed(1)} MiB`,
|
| 381 |
+
]);
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
export const testing = {
|
| 385 |
+
aggregateWorkerResults,
|
| 386 |
+
benchmark,
|
| 387 |
+
parseOptions,
|
| 388 |
+
parseWorkerProcessResult,
|
| 389 |
+
summarizeTimings: summarizeBenchmarkTimings,
|
| 390 |
+
};
|
| 391 |
+
|
| 392 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 393 |
+
await runBenchmarkEntrypoint("bench-agent-concurrency", main);
|
| 394 |
+
}
|
scripts/bench-cli-startup.ts
ADDED
|
@@ -0,0 +1,1747 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Bench Cli Startup script supports OpenClaw repository automation.
|
| 2 |
+
import { execFileSync, spawn } from "node:child_process";
|
| 3 |
+
import {
|
| 4 |
+
mkdirSync,
|
| 5 |
+
mkdtempSync,
|
| 6 |
+
readFileSync,
|
| 7 |
+
readdirSync,
|
| 8 |
+
realpathSync,
|
| 9 |
+
rmSync,
|
| 10 |
+
writeFileSync,
|
| 11 |
+
} from "node:fs";
|
| 12 |
+
import os from "node:os";
|
| 13 |
+
import path from "node:path";
|
| 14 |
+
import { pathToFileURL } from "node:url";
|
| 15 |
+
import { expectDefined } from "../packages/normalization-core/src/expect.js";
|
| 16 |
+
import {
|
| 17 |
+
assertCompatibleCliStartupExecutionModes,
|
| 18 |
+
assertCompatibleCliStartupMemoryMetrics,
|
| 19 |
+
CLI_RUNTIME_MEMORY_METRIC,
|
| 20 |
+
type CliStartupExecutionMode,
|
| 21 |
+
cliStartupMemoryMetric,
|
| 22 |
+
} from "./lib/cli-startup-memory-contract.mts";
|
| 23 |
+
import {
|
| 24 |
+
inspectManagedProcessGroup,
|
| 25 |
+
terminateManagedChild,
|
| 26 |
+
waitForManagedProcessGroupExit,
|
| 27 |
+
} from "./lib/managed-child-process.mts";
|
| 28 |
+
|
| 29 |
+
type CommandCase = {
|
| 30 |
+
id: string;
|
| 31 |
+
name: string;
|
| 32 |
+
args: string[];
|
| 33 |
+
presets: readonly string[];
|
| 34 |
+
stateScope?: "case" | "sample";
|
| 35 |
+
expectedExitCodes?: readonly number[];
|
| 36 |
+
expectedNonzeroOutputIncludes?: readonly string[];
|
| 37 |
+
firstOutputBudgetMs?: number;
|
| 38 |
+
exitBudgetMs?: number;
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
type Sample = {
|
| 42 |
+
ms: number;
|
| 43 |
+
firstOutputMs: number | null;
|
| 44 |
+
maxRssMb: number | null;
|
| 45 |
+
memory?: SampleMemory;
|
| 46 |
+
exitCode: number | null;
|
| 47 |
+
signal: string | null;
|
| 48 |
+
startedAt?: string;
|
| 49 |
+
endedAt?: string;
|
| 50 |
+
timedOut?: boolean;
|
| 51 |
+
stdoutTail?: string;
|
| 52 |
+
stderrTail?: string;
|
| 53 |
+
};
|
| 54 |
+
|
| 55 |
+
type RssObservation = {
|
| 56 |
+
pid: number;
|
| 57 |
+
parentPid: number;
|
| 58 |
+
matchesArguments: boolean;
|
| 59 |
+
matchesInvocation: boolean;
|
| 60 |
+
maxRssBytes: number | null;
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
type SampleMemory = {
|
| 64 |
+
runtimePid: number | null;
|
| 65 |
+
processes: Array<{
|
| 66 |
+
pid: number;
|
| 67 |
+
parentPid: number;
|
| 68 |
+
role: "runtime" | "launcher" | "auxiliary" | "unresolved";
|
| 69 |
+
metricKind: "process-high-water-rss";
|
| 70 |
+
maxRssBytes: number | null;
|
| 71 |
+
}>;
|
| 72 |
+
error?: string;
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
type CaseRuns = {
|
| 76 |
+
warmupSamples: Sample[];
|
| 77 |
+
samples: Sample[];
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
type SummaryStats = {
|
| 81 |
+
avg: number;
|
| 82 |
+
p50: number;
|
| 83 |
+
p95: number;
|
| 84 |
+
min: number;
|
| 85 |
+
max: number;
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
type CaseSummary = {
|
| 89 |
+
sampleCount: number;
|
| 90 |
+
durationMs: SummaryStats;
|
| 91 |
+
firstOutputMs: SummaryStats | null;
|
| 92 |
+
maxRssMb: SummaryStats | null;
|
| 93 |
+
exitSummary: string;
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
type SuiteResult = {
|
| 97 |
+
entry: string;
|
| 98 |
+
executionMode?: CliStartupExecutionMode;
|
| 99 |
+
memoryMetric?: string;
|
| 100 |
+
cases: Array<{
|
| 101 |
+
id: string;
|
| 102 |
+
name: string;
|
| 103 |
+
args: string[];
|
| 104 |
+
expectedExitCodes?: number[];
|
| 105 |
+
expectedNonzeroOutputIncludes?: string[];
|
| 106 |
+
contract: {
|
| 107 |
+
firstOutputBudgetMs: number | null;
|
| 108 |
+
exitBudgetMs: number | null;
|
| 109 |
+
} | null;
|
| 110 |
+
warmupSamples?: Sample[];
|
| 111 |
+
samples: Sample[];
|
| 112 |
+
summary: CaseSummary;
|
| 113 |
+
}>;
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
type BenchmarkReport = {
|
| 117 |
+
primary: SuiteResult;
|
| 118 |
+
secondary?: SuiteResult | null;
|
| 119 |
+
};
|
| 120 |
+
|
| 121 |
+
type CaseDelta = {
|
| 122 |
+
id: string;
|
| 123 |
+
name: string;
|
| 124 |
+
durationAvgDeltaMs: number;
|
| 125 |
+
durationAvgDeltaPct: number;
|
| 126 |
+
maxRssAvgDeltaMb: number | null;
|
| 127 |
+
maxRssAvgDeltaPct: number | null;
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
type BenchmarkComparison = {
|
| 131 |
+
baseline: string;
|
| 132 |
+
candidate: string;
|
| 133 |
+
deltas: CaseDelta[];
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
type BenchmarkComparisonResult = {
|
| 137 |
+
baseline: SuiteResult;
|
| 138 |
+
candidate: SuiteResult;
|
| 139 |
+
comparison: BenchmarkComparison;
|
| 140 |
+
};
|
| 141 |
+
|
| 142 |
+
type CliOptions = {
|
| 143 |
+
cases: CommandCase[];
|
| 144 |
+
compareBaseline?: string;
|
| 145 |
+
compareCandidate?: string;
|
| 146 |
+
entryPrimary: string;
|
| 147 |
+
entrySecondary?: string;
|
| 148 |
+
runs: number;
|
| 149 |
+
warmup: number;
|
| 150 |
+
timeoutMs: number;
|
| 151 |
+
runtimeRss: boolean;
|
| 152 |
+
json: boolean;
|
| 153 |
+
output?: string;
|
| 154 |
+
cpuProfDir?: string;
|
| 155 |
+
heapProfDir?: string;
|
| 156 |
+
};
|
| 157 |
+
|
| 158 |
+
const DEFAULT_RUNS = 5;
|
| 159 |
+
const DEFAULT_WARMUP = 1;
|
| 160 |
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
| 161 |
+
const DEFAULT_TIMEOUT_KILL_GRACE_MS = 1_000;
|
| 162 |
+
const TIMEOUT_KILL_GRACE_MS = resolveTimeoutKillGraceMs(process.env);
|
| 163 |
+
const DEFAULT_ENTRY = "openclaw.mjs";
|
| 164 |
+
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
|
| 165 |
+
|
| 166 |
+
type SampleTransport = {
|
| 167 |
+
prefix: string[];
|
| 168 |
+
binary: string;
|
| 169 |
+
env: Record<string, string>;
|
| 170 |
+
cwd: string;
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
function sampleTransport(): SampleTransport | undefined {
|
| 174 |
+
const raw = process.env.OPENCLAW_BENCH_TRANSPORT_JSON;
|
| 175 |
+
if (raw === undefined) {
|
| 176 |
+
return undefined;
|
| 177 |
+
}
|
| 178 |
+
const value: unknown = JSON.parse(raw);
|
| 179 |
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
| 180 |
+
throw new Error("Invalid benchmark transport");
|
| 181 |
+
}
|
| 182 |
+
const prefix = "prefix" in value ? value.prefix : undefined;
|
| 183 |
+
const binary = "binary" in value ? value.binary : undefined;
|
| 184 |
+
const env = "env" in value ? value.env : undefined;
|
| 185 |
+
const cwd = "cwd" in value ? value.cwd : undefined;
|
| 186 |
+
if (
|
| 187 |
+
!Array.isArray(prefix) ||
|
| 188 |
+
prefix.length === 0 ||
|
| 189 |
+
!prefix.every(
|
| 190 |
+
(part: unknown) => typeof part === "string" && part.length > 0 && !part.includes("\0"),
|
| 191 |
+
) ||
|
| 192 |
+
typeof prefix[0] !== "string" ||
|
| 193 |
+
!path.isAbsolute(prefix[0]) ||
|
| 194 |
+
typeof binary !== "string" ||
|
| 195 |
+
!path.isAbsolute(binary) ||
|
| 196 |
+
binary.includes("\0") ||
|
| 197 |
+
!env ||
|
| 198 |
+
typeof env !== "object" ||
|
| 199 |
+
Array.isArray(env)
|
| 200 |
+
) {
|
| 201 |
+
throw new Error("Invalid benchmark transport");
|
| 202 |
+
}
|
| 203 |
+
const fixedEnv: Record<string, string> = {};
|
| 204 |
+
for (const [key, entry] of Object.entries(env)) {
|
| 205 |
+
if (!/^[A-Z_][A-Z0-9_]*$/u.test(key) || typeof entry !== "string" || entry.includes("\0")) {
|
| 206 |
+
throw new Error("Invalid benchmark transport environment");
|
| 207 |
+
}
|
| 208 |
+
fixedEnv[key] = entry;
|
| 209 |
+
}
|
| 210 |
+
if (!fixedEnv.HOME || !path.isAbsolute(fixedEnv.HOME) || !fixedEnv.PATH) {
|
| 211 |
+
throw new Error("Benchmark transport requires fixed HOME and PATH");
|
| 212 |
+
}
|
| 213 |
+
const directory = cwd === undefined ? fixedEnv.HOME : cwd;
|
| 214 |
+
if (typeof directory !== "string" || !path.isAbsolute(directory) || directory.includes("\0")) {
|
| 215 |
+
throw new Error("Invalid benchmark transport cwd");
|
| 216 |
+
}
|
| 217 |
+
return { prefix, binary, env: fixedEnv, cwd: directory };
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function transportedCommand(
|
| 221 |
+
transport: SampleTransport,
|
| 222 |
+
args: string[],
|
| 223 |
+
env: Record<string, string> = {},
|
| 224 |
+
timeoutMs?: number,
|
| 225 |
+
): { command: string; args: string[] } {
|
| 226 |
+
return {
|
| 227 |
+
command: expectDefined(transport.prefix[0], "benchmark transport command"),
|
| 228 |
+
args: [
|
| 229 |
+
...transport.prefix.slice(1),
|
| 230 |
+
"/usr/bin/env",
|
| 231 |
+
"-C",
|
| 232 |
+
transport.cwd,
|
| 233 |
+
"-i",
|
| 234 |
+
...Object.entries({ ...transport.env, ...env }).map(([key, value]) => `${key}=${value}`),
|
| 235 |
+
...(timeoutMs === undefined
|
| 236 |
+
? []
|
| 237 |
+
: ["/usr/bin/timeout", "--signal=TERM", "--kill-after=1s", `${timeoutMs / 1000}s`]),
|
| 238 |
+
transport.binary,
|
| 239 |
+
...args,
|
| 240 |
+
],
|
| 241 |
+
};
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
function sampleFilesystem(
|
| 245 |
+
transport: SampleTransport,
|
| 246 |
+
operation: "create" | "prepare" | "remove",
|
| 247 |
+
root?: string,
|
| 248 |
+
config?: Record<string, unknown> | null,
|
| 249 |
+
hook?: string,
|
| 250 |
+
): string {
|
| 251 |
+
// Only the SUT opens these paths. Its replies are never read as paths on the runner.
|
| 252 |
+
const launch = transportedCommand(transport, [
|
| 253 |
+
"--input-type=module",
|
| 254 |
+
"-e",
|
| 255 |
+
`import fs from "node:fs"; import os from "node:os"; import path from "node:path";
|
| 256 |
+
const {operation,root,config,hook} = JSON.parse(fs.readFileSync(0,"utf8"));
|
| 257 |
+
if (operation === "create") process.stdout.write(fs.mkdtempSync(path.join(os.tmpdir(),"openclaw-cli-bench-home-")));
|
| 258 |
+
else if (operation === "prepare") {
|
| 259 |
+
fs.mkdirSync(path.join(root,".openclaw"),{recursive:true});
|
| 260 |
+
if (config) fs.writeFileSync(path.join(root,".openclaw/openclaw.json"),JSON.stringify(config)+"\\n");
|
| 261 |
+
fs.writeFileSync(path.join(root,"measure-rss.mjs"),hook);
|
| 262 |
+
} else if (operation === "remove") fs.rmSync(root,{recursive:true,force:true});
|
| 263 |
+
else throw new Error("Invalid sample filesystem operation");`,
|
| 264 |
+
]);
|
| 265 |
+
return execFileSync(launch.command, launch.args, {
|
| 266 |
+
input: JSON.stringify({ operation, root, config, hook }),
|
| 267 |
+
encoding: "utf8",
|
| 268 |
+
timeout: 10_000,
|
| 269 |
+
maxBuffer: 64 * 1024,
|
| 270 |
+
env: { PATH: process.env.PATH },
|
| 271 |
+
});
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function createSampleRoot(transport?: SampleTransport): string {
|
| 275 |
+
if (!transport) {
|
| 276 |
+
return mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-"));
|
| 277 |
+
}
|
| 278 |
+
const root = sampleFilesystem(transport, "create");
|
| 279 |
+
if (!path.isAbsolute(root) || /[\0\r\n]/u.test(root)) {
|
| 280 |
+
throw new Error("Invalid SUT sample directory");
|
| 281 |
+
}
|
| 282 |
+
return root;
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function removeSampleRoot(root: string, transport?: SampleTransport): void {
|
| 286 |
+
if (transport) {
|
| 287 |
+
sampleFilesystem(transport, "remove", root);
|
| 288 |
+
} else {
|
| 289 |
+
rmSync(root, { recursive: true, force: true });
|
| 290 |
+
}
|
| 291 |
+
}
|
| 292 |
+
|
| 293 |
+
function resolveTimeoutKillGraceMs(env: NodeJS.ProcessEnv): number {
|
| 294 |
+
const raw = env.VITEST ? env.OPENCLAW_TEST_CLI_STARTUP_TIMEOUT_KILL_GRACE_MS : undefined;
|
| 295 |
+
if (!raw || !/^\d+$/u.test(raw)) {
|
| 296 |
+
return DEFAULT_TIMEOUT_KILL_GRACE_MS;
|
| 297 |
+
}
|
| 298 |
+
const parsed = Number(raw);
|
| 299 |
+
return Number.isSafeInteger(parsed) ? parsed : DEFAULT_TIMEOUT_KILL_GRACE_MS;
|
| 300 |
+
}
|
| 301 |
+
const VALUE_FLAGS = new Set([
|
| 302 |
+
"--case",
|
| 303 |
+
"--compare-baseline",
|
| 304 |
+
"--compare-candidate",
|
| 305 |
+
"--cpu-prof-dir",
|
| 306 |
+
"--entry",
|
| 307 |
+
"--entry-primary",
|
| 308 |
+
"--entry-secondary",
|
| 309 |
+
"--heap-prof-dir",
|
| 310 |
+
"--output",
|
| 311 |
+
"--preset",
|
| 312 |
+
"--runs",
|
| 313 |
+
"--timeout-ms",
|
| 314 |
+
"--warmup",
|
| 315 |
+
]);
|
| 316 |
+
const BOOLEAN_FLAGS = new Set(["--help", "--json", "--runtime-rss"]);
|
| 317 |
+
|
| 318 |
+
const COMMAND_CASES: readonly CommandCase[] = [
|
| 319 |
+
{
|
| 320 |
+
id: "version",
|
| 321 |
+
name: "--version",
|
| 322 |
+
args: ["--version"],
|
| 323 |
+
presets: ["startup", "response"],
|
| 324 |
+
firstOutputBudgetMs: 1_000,
|
| 325 |
+
exitBudgetMs: 2_000,
|
| 326 |
+
},
|
| 327 |
+
{
|
| 328 |
+
id: "help",
|
| 329 |
+
name: "--help",
|
| 330 |
+
args: ["--help"],
|
| 331 |
+
presets: ["startup", "response"],
|
| 332 |
+
firstOutputBudgetMs: 1_000,
|
| 333 |
+
exitBudgetMs: 2_000,
|
| 334 |
+
},
|
| 335 |
+
{
|
| 336 |
+
id: "onboardHelp",
|
| 337 |
+
name: "onboard --help",
|
| 338 |
+
args: ["onboard", "--help"],
|
| 339 |
+
presets: ["response"],
|
| 340 |
+
firstOutputBudgetMs: 2_500,
|
| 341 |
+
exitBudgetMs: 6_000,
|
| 342 |
+
},
|
| 343 |
+
{
|
| 344 |
+
id: "setupHelp",
|
| 345 |
+
name: "setup --help",
|
| 346 |
+
args: ["setup", "--help"],
|
| 347 |
+
presets: ["response"],
|
| 348 |
+
firstOutputBudgetMs: 2_500,
|
| 349 |
+
exitBudgetMs: 6_000,
|
| 350 |
+
},
|
| 351 |
+
{
|
| 352 |
+
id: "configureHelp",
|
| 353 |
+
name: "configure --help",
|
| 354 |
+
args: ["configure", "--help"],
|
| 355 |
+
presets: ["response"],
|
| 356 |
+
firstOutputBudgetMs: 2_500,
|
| 357 |
+
exitBudgetMs: 6_000,
|
| 358 |
+
},
|
| 359 |
+
{
|
| 360 |
+
id: "channelsAddHelp",
|
| 361 |
+
name: "channels add --help",
|
| 362 |
+
args: ["channels", "add", "--help"],
|
| 363 |
+
presets: ["response"],
|
| 364 |
+
firstOutputBudgetMs: 2_500,
|
| 365 |
+
exitBudgetMs: 6_000,
|
| 366 |
+
},
|
| 367 |
+
{
|
| 368 |
+
id: "channelsParent",
|
| 369 |
+
name: "channels",
|
| 370 |
+
args: ["channels"],
|
| 371 |
+
presets: ["response"],
|
| 372 |
+
firstOutputBudgetMs: 2_500,
|
| 373 |
+
exitBudgetMs: 6_000,
|
| 374 |
+
},
|
| 375 |
+
{
|
| 376 |
+
id: "doctorHelp",
|
| 377 |
+
name: "doctor --help",
|
| 378 |
+
args: ["doctor", "--help"],
|
| 379 |
+
presets: ["response"],
|
| 380 |
+
firstOutputBudgetMs: 2_500,
|
| 381 |
+
exitBudgetMs: 6_000,
|
| 382 |
+
},
|
| 383 |
+
{
|
| 384 |
+
id: "modelsHelp",
|
| 385 |
+
name: "models --help",
|
| 386 |
+
args: ["models", "--help"],
|
| 387 |
+
presets: ["response"],
|
| 388 |
+
firstOutputBudgetMs: 2_500,
|
| 389 |
+
exitBudgetMs: 6_000,
|
| 390 |
+
},
|
| 391 |
+
{
|
| 392 |
+
id: "pluginsHelp",
|
| 393 |
+
name: "plugins --help",
|
| 394 |
+
args: ["plugins", "--help"],
|
| 395 |
+
presets: ["response"],
|
| 396 |
+
firstOutputBudgetMs: 2_500,
|
| 397 |
+
exitBudgetMs: 6_000,
|
| 398 |
+
},
|
| 399 |
+
{
|
| 400 |
+
id: "pluginsParent",
|
| 401 |
+
name: "plugins",
|
| 402 |
+
args: ["plugins"],
|
| 403 |
+
presets: ["response"],
|
| 404 |
+
firstOutputBudgetMs: 2_500,
|
| 405 |
+
exitBudgetMs: 6_000,
|
| 406 |
+
},
|
| 407 |
+
{
|
| 408 |
+
id: "pluginsListJson",
|
| 409 |
+
name: "plugins list --json",
|
| 410 |
+
args: ["plugins", "list", "--json"],
|
| 411 |
+
presets: ["response", "real"],
|
| 412 |
+
firstOutputBudgetMs: 2_500,
|
| 413 |
+
exitBudgetMs: 6_000,
|
| 414 |
+
},
|
| 415 |
+
{
|
| 416 |
+
id: "gatewayHelp",
|
| 417 |
+
name: "gateway --help",
|
| 418 |
+
args: ["gateway", "--help"],
|
| 419 |
+
presets: ["response"],
|
| 420 |
+
firstOutputBudgetMs: 2_500,
|
| 421 |
+
exitBudgetMs: 6_000,
|
| 422 |
+
},
|
| 423 |
+
{
|
| 424 |
+
id: "agentsHelp",
|
| 425 |
+
name: "agents --help",
|
| 426 |
+
args: ["agents", "--help"],
|
| 427 |
+
presets: ["response"],
|
| 428 |
+
firstOutputBudgetMs: 3_500,
|
| 429 |
+
exitBudgetMs: 8_000,
|
| 430 |
+
},
|
| 431 |
+
{
|
| 432 |
+
id: "sessionsHelp",
|
| 433 |
+
name: "sessions --help",
|
| 434 |
+
args: ["sessions", "--help"],
|
| 435 |
+
presets: ["response"],
|
| 436 |
+
firstOutputBudgetMs: 2_500,
|
| 437 |
+
exitBudgetMs: 6_000,
|
| 438 |
+
},
|
| 439 |
+
{
|
| 440 |
+
id: "tasksHelp",
|
| 441 |
+
name: "tasks --help",
|
| 442 |
+
args: ["tasks", "--help"],
|
| 443 |
+
presets: ["response"],
|
| 444 |
+
firstOutputBudgetMs: 2_500,
|
| 445 |
+
exitBudgetMs: 6_000,
|
| 446 |
+
},
|
| 447 |
+
{
|
| 448 |
+
id: "messageHelp",
|
| 449 |
+
name: "message --help",
|
| 450 |
+
args: ["message", "--help"],
|
| 451 |
+
presets: ["response"],
|
| 452 |
+
firstOutputBudgetMs: 2_500,
|
| 453 |
+
exitBudgetMs: 6_000,
|
| 454 |
+
},
|
| 455 |
+
{
|
| 456 |
+
id: "pairingHelp",
|
| 457 |
+
name: "pairing --help",
|
| 458 |
+
args: ["pairing", "--help"],
|
| 459 |
+
presets: ["response"],
|
| 460 |
+
firstOutputBudgetMs: 2_500,
|
| 461 |
+
exitBudgetMs: 6_000,
|
| 462 |
+
},
|
| 463 |
+
{
|
| 464 |
+
id: "authHelp",
|
| 465 |
+
name: "auth --help",
|
| 466 |
+
args: ["auth", "--help"],
|
| 467 |
+
presets: ["response"],
|
| 468 |
+
firstOutputBudgetMs: 2_500,
|
| 469 |
+
exitBudgetMs: 6_000,
|
| 470 |
+
},
|
| 471 |
+
{
|
| 472 |
+
id: "configHelp",
|
| 473 |
+
name: "config --help",
|
| 474 |
+
args: ["config", "--help"],
|
| 475 |
+
presets: ["response"],
|
| 476 |
+
firstOutputBudgetMs: 2_500,
|
| 477 |
+
exitBudgetMs: 6_000,
|
| 478 |
+
},
|
| 479 |
+
{
|
| 480 |
+
id: "secretsHelp",
|
| 481 |
+
name: "secrets --help",
|
| 482 |
+
args: ["secrets", "--help"],
|
| 483 |
+
presets: ["response"],
|
| 484 |
+
firstOutputBudgetMs: 2_500,
|
| 485 |
+
exitBudgetMs: 6_000,
|
| 486 |
+
},
|
| 487 |
+
{
|
| 488 |
+
id: "skillsHelp",
|
| 489 |
+
name: "skills --help",
|
| 490 |
+
args: ["skills", "--help"],
|
| 491 |
+
presets: ["response"],
|
| 492 |
+
firstOutputBudgetMs: 2_500,
|
| 493 |
+
exitBudgetMs: 6_000,
|
| 494 |
+
},
|
| 495 |
+
{
|
| 496 |
+
id: "nodesHelp",
|
| 497 |
+
name: "nodes --help",
|
| 498 |
+
args: ["nodes", "--help"],
|
| 499 |
+
presets: ["response"],
|
| 500 |
+
firstOutputBudgetMs: 3_500,
|
| 501 |
+
exitBudgetMs: 8_000,
|
| 502 |
+
},
|
| 503 |
+
{
|
| 504 |
+
id: "directoryHelp",
|
| 505 |
+
name: "directory --help",
|
| 506 |
+
args: ["directory", "--help"],
|
| 507 |
+
presets: ["response"],
|
| 508 |
+
firstOutputBudgetMs: 2_500,
|
| 509 |
+
exitBudgetMs: 6_000,
|
| 510 |
+
},
|
| 511 |
+
{
|
| 512 |
+
id: "sandboxHelp",
|
| 513 |
+
name: "sandbox --help",
|
| 514 |
+
args: ["sandbox", "--help"],
|
| 515 |
+
presets: ["response"],
|
| 516 |
+
firstOutputBudgetMs: 2_500,
|
| 517 |
+
exitBudgetMs: 6_000,
|
| 518 |
+
},
|
| 519 |
+
{
|
| 520 |
+
id: "devicesParent",
|
| 521 |
+
name: "devices",
|
| 522 |
+
args: ["devices"],
|
| 523 |
+
presets: ["response"],
|
| 524 |
+
firstOutputBudgetMs: 2_500,
|
| 525 |
+
exitBudgetMs: 6_000,
|
| 526 |
+
},
|
| 527 |
+
{
|
| 528 |
+
id: "mcpParent",
|
| 529 |
+
name: "mcp",
|
| 530 |
+
args: ["mcp"],
|
| 531 |
+
presets: ["response"],
|
| 532 |
+
firstOutputBudgetMs: 2_500,
|
| 533 |
+
exitBudgetMs: 6_000,
|
| 534 |
+
},
|
| 535 |
+
{
|
| 536 |
+
id: "browserHelp",
|
| 537 |
+
name: "browser --help",
|
| 538 |
+
args: ["browser", "--help"],
|
| 539 |
+
presets: ["response"],
|
| 540 |
+
firstOutputBudgetMs: 1_500,
|
| 541 |
+
exitBudgetMs: 3_000,
|
| 542 |
+
},
|
| 543 |
+
{
|
| 544 |
+
id: "webhooksHelp",
|
| 545 |
+
name: "webhooks --help",
|
| 546 |
+
args: ["webhooks", "--help"],
|
| 547 |
+
presets: ["response"],
|
| 548 |
+
firstOutputBudgetMs: 2_500,
|
| 549 |
+
exitBudgetMs: 6_000,
|
| 550 |
+
},
|
| 551 |
+
{
|
| 552 |
+
id: "health",
|
| 553 |
+
name: "health",
|
| 554 |
+
args: ["health"],
|
| 555 |
+
presets: ["startup", "real"],
|
| 556 |
+
expectedExitCodes: [0, 1],
|
| 557 |
+
expectedNonzeroOutputIncludes: ["Gateway target:"],
|
| 558 |
+
},
|
| 559 |
+
{
|
| 560 |
+
id: "healthJson",
|
| 561 |
+
name: "health --json",
|
| 562 |
+
args: ["health", "--json"],
|
| 563 |
+
presets: ["startup"],
|
| 564 |
+
expectedExitCodes: [0, 1],
|
| 565 |
+
expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'],
|
| 566 |
+
},
|
| 567 |
+
{
|
| 568 |
+
id: "statusJson",
|
| 569 |
+
name: "status --json",
|
| 570 |
+
args: ["status", "--json"],
|
| 571 |
+
presets: ["startup", "real"],
|
| 572 |
+
},
|
| 573 |
+
{ id: "status", name: "status", args: ["status"], presets: ["startup", "real"] },
|
| 574 |
+
{ id: "sessions", name: "sessions", args: ["sessions"], presets: ["real"] },
|
| 575 |
+
{
|
| 576 |
+
id: "sessionsJson",
|
| 577 |
+
name: "sessions --json",
|
| 578 |
+
args: ["sessions", "--json"],
|
| 579 |
+
presets: ["real"],
|
| 580 |
+
},
|
| 581 |
+
{
|
| 582 |
+
id: "tasksJson",
|
| 583 |
+
name: "tasks --json",
|
| 584 |
+
args: ["tasks", "--json"],
|
| 585 |
+
presets: ["real"],
|
| 586 |
+
},
|
| 587 |
+
{
|
| 588 |
+
id: "tasksListJson",
|
| 589 |
+
name: "tasks list --json",
|
| 590 |
+
args: ["tasks", "list", "--json"],
|
| 591 |
+
presets: ["real"],
|
| 592 |
+
},
|
| 593 |
+
{
|
| 594 |
+
id: "tasksAuditJson",
|
| 595 |
+
name: "tasks audit --json",
|
| 596 |
+
args: ["tasks", "audit", "--json"],
|
| 597 |
+
presets: ["real"],
|
| 598 |
+
},
|
| 599 |
+
{
|
| 600 |
+
id: "agentsListJson",
|
| 601 |
+
name: "agents list --json",
|
| 602 |
+
args: ["agents", "list", "--json"],
|
| 603 |
+
presets: ["real"],
|
| 604 |
+
},
|
| 605 |
+
{
|
| 606 |
+
id: "gatewayStatus",
|
| 607 |
+
name: "gateway status",
|
| 608 |
+
args: ["gateway", "status"],
|
| 609 |
+
presets: ["real"],
|
| 610 |
+
},
|
| 611 |
+
{
|
| 612 |
+
id: "gatewayStatusJson",
|
| 613 |
+
name: "gateway status --json",
|
| 614 |
+
args: ["gateway", "status", "--json"],
|
| 615 |
+
presets: ["real"],
|
| 616 |
+
},
|
| 617 |
+
{
|
| 618 |
+
id: "gatewayHealthJson",
|
| 619 |
+
name: "gateway health --json",
|
| 620 |
+
args: ["gateway", "health", "--json"],
|
| 621 |
+
presets: ["real"],
|
| 622 |
+
expectedExitCodes: [0, 1],
|
| 623 |
+
expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'],
|
| 624 |
+
},
|
| 625 |
+
{
|
| 626 |
+
id: "gatewayHealthJsonWarmState",
|
| 627 |
+
name: "gateway health --json (warm state)",
|
| 628 |
+
args: ["gateway", "health", "--json"],
|
| 629 |
+
presets: [],
|
| 630 |
+
stateScope: "case",
|
| 631 |
+
},
|
| 632 |
+
{
|
| 633 |
+
id: "gatewayHealthJsonFreshState",
|
| 634 |
+
name: "gateway health --json (fresh state)",
|
| 635 |
+
args: ["gateway", "health", "--json"],
|
| 636 |
+
presets: [],
|
| 637 |
+
stateScope: "sample",
|
| 638 |
+
},
|
| 639 |
+
{
|
| 640 |
+
id: "configGetGatewayPort",
|
| 641 |
+
name: "config get gateway.port",
|
| 642 |
+
args: ["config", "get", "gateway.port"],
|
| 643 |
+
presets: ["real"],
|
| 644 |
+
expectedExitCodes: [0, 1],
|
| 645 |
+
expectedNonzeroOutputIncludes: ["Config path not found: gateway.port"],
|
| 646 |
+
},
|
| 647 |
+
] as const;
|
| 648 |
+
|
| 649 |
+
function parseFlagValue(flag: string): string | undefined {
|
| 650 |
+
const idx = process.argv.indexOf(flag);
|
| 651 |
+
if (idx === -1) {
|
| 652 |
+
return undefined;
|
| 653 |
+
}
|
| 654 |
+
const value = process.argv[idx + 1];
|
| 655 |
+
if (!value || value.startsWith("-")) {
|
| 656 |
+
throw new Error(`${flag} requires a value`);
|
| 657 |
+
}
|
| 658 |
+
return value;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
function hasFlag(flag: string): boolean {
|
| 662 |
+
return process.argv.includes(flag);
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
function parseRepeatableFlag(flag: string): string[] {
|
| 666 |
+
const values: string[] = [];
|
| 667 |
+
for (let i = 0; i < process.argv.length; i += 1) {
|
| 668 |
+
const value = process.argv[i + 1];
|
| 669 |
+
if (process.argv[i] === flag && value && !value.startsWith("-")) {
|
| 670 |
+
values.push(value);
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
return values;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
function validateCliArgs(argv: readonly string[] = process.argv.slice(2)): void {
|
| 677 |
+
const seenSingleValueFlags = new Set<string>();
|
| 678 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 679 |
+
const arg = expectDefined(argv[index], `CLI benchmark argument at index ${index}`);
|
| 680 |
+
if (VALUE_FLAGS.has(arg)) {
|
| 681 |
+
if (arg !== "--case") {
|
| 682 |
+
if (seenSingleValueFlags.has(arg)) {
|
| 683 |
+
throw new Error(`${arg} was provided more than once`);
|
| 684 |
+
}
|
| 685 |
+
seenSingleValueFlags.add(arg);
|
| 686 |
+
}
|
| 687 |
+
const value = argv[index + 1];
|
| 688 |
+
if (!value || value.startsWith("-")) {
|
| 689 |
+
throw new Error(`${arg} requires a value`);
|
| 690 |
+
}
|
| 691 |
+
index += 1;
|
| 692 |
+
continue;
|
| 693 |
+
}
|
| 694 |
+
if (BOOLEAN_FLAGS.has(arg)) {
|
| 695 |
+
continue;
|
| 696 |
+
}
|
| 697 |
+
throw new Error(`Unknown argument: ${arg}`);
|
| 698 |
+
}
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
function parsePositiveInt(raw: string | undefined, fallback: number, label = "value"): number {
|
| 702 |
+
return parseIntegerOption(raw, fallback, label, 1);
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
function parseNonNegativeInt(raw: string | undefined, fallback: number, label = "value"): number {
|
| 706 |
+
return parseIntegerOption(raw, fallback, label, 0);
|
| 707 |
+
}
|
| 708 |
+
|
| 709 |
+
// This runner is checked out from trusted main beside frozen candidates, whose
|
| 710 |
+
// root dependencies need not include current workspace packages.
|
| 711 |
+
function parseIntegerOption(
|
| 712 |
+
raw: string | undefined,
|
| 713 |
+
fallback: number,
|
| 714 |
+
label: string,
|
| 715 |
+
min: number,
|
| 716 |
+
): number {
|
| 717 |
+
const value = raw?.trim();
|
| 718 |
+
if (!value) {
|
| 719 |
+
return fallback;
|
| 720 |
+
}
|
| 721 |
+
if (!/^\d+$/u.test(value)) {
|
| 722 |
+
throw new Error(`${label} must be an integer >= ${min}; got ${JSON.stringify(raw)}`);
|
| 723 |
+
}
|
| 724 |
+
const parsed = Number(value);
|
| 725 |
+
if (!Number.isSafeInteger(parsed) || parsed < min) {
|
| 726 |
+
throw new Error(`${label} must be an integer >= ${min}; got ${JSON.stringify(raw)}`);
|
| 727 |
+
}
|
| 728 |
+
return parsed;
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
function parseGatewayPortEnv(raw: string | undefined): number {
|
| 732 |
+
const value = raw?.trim();
|
| 733 |
+
if (!value) {
|
| 734 |
+
return 32123;
|
| 735 |
+
}
|
| 736 |
+
const bracketHostMatch = /^\[[^\]]+\]:(\d+)$/u.exec(value);
|
| 737 |
+
if (bracketHostMatch) {
|
| 738 |
+
return parsePositiveInt(bracketHostMatch[1], 32123, "OPENCLAW_GATEWAY_PORT");
|
| 739 |
+
}
|
| 740 |
+
if (value.startsWith("[") && value.endsWith("]")) {
|
| 741 |
+
return 32123;
|
| 742 |
+
}
|
| 743 |
+
const colonCount = value.split(":").length - 1;
|
| 744 |
+
if (colonCount > 1) {
|
| 745 |
+
return 32123;
|
| 746 |
+
}
|
| 747 |
+
const portRaw = colonCount === 1 ? value.split(":")[1] : value;
|
| 748 |
+
return parsePositiveInt(portRaw, 32123, "OPENCLAW_GATEWAY_PORT");
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
function parsePresets(raw: string | undefined): string[] {
|
| 752 |
+
if (!raw) {
|
| 753 |
+
return ["startup"];
|
| 754 |
+
}
|
| 755 |
+
const values = raw
|
| 756 |
+
.split(",")
|
| 757 |
+
.map((value) => value.trim())
|
| 758 |
+
.filter(Boolean);
|
| 759 |
+
if (values.includes("all")) {
|
| 760 |
+
return ["startup", "real", "response"];
|
| 761 |
+
}
|
| 762 |
+
return values.length > 0 ? values : ["startup"];
|
| 763 |
+
}
|
| 764 |
+
|
| 765 |
+
function resolveCases(options: { presets: string[]; caseIds: string[] }): CommandCase[] {
|
| 766 |
+
const byId = new Map(COMMAND_CASES.map((commandCase) => [commandCase.id, commandCase]));
|
| 767 |
+
if (options.caseIds.length > 0) {
|
| 768 |
+
const seenIds = new Set<string>();
|
| 769 |
+
return options.caseIds.map((id) => {
|
| 770 |
+
if (seenIds.has(id)) {
|
| 771 |
+
throw new Error(`Duplicate --case "${id}"`);
|
| 772 |
+
}
|
| 773 |
+
seenIds.add(id);
|
| 774 |
+
const commandCase = byId.get(id);
|
| 775 |
+
if (!commandCase) {
|
| 776 |
+
throw new Error(`Unknown --case "${id}"`);
|
| 777 |
+
}
|
| 778 |
+
return commandCase;
|
| 779 |
+
});
|
| 780 |
+
}
|
| 781 |
+
return COMMAND_CASES.filter((commandCase) =>
|
| 782 |
+
commandCase.presets.some((preset) => options.presets.includes(preset)),
|
| 783 |
+
);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
function median(values: number[]): number {
|
| 787 |
+
if (values.length === 0) {
|
| 788 |
+
return 0;
|
| 789 |
+
}
|
| 790 |
+
const sorted = [...values].toSorted((a, b) => a - b);
|
| 791 |
+
const mid = Math.floor(sorted.length / 2);
|
| 792 |
+
if (sorted.length % 2 === 0) {
|
| 793 |
+
return (
|
| 794 |
+
(expectDefined(sorted[mid - 1], "lower middle CLI benchmark sample") +
|
| 795 |
+
expectDefined(sorted[mid], "upper middle CLI benchmark sample")) /
|
| 796 |
+
2
|
| 797 |
+
);
|
| 798 |
+
}
|
| 799 |
+
return expectDefined(sorted[mid], "middle CLI benchmark sample");
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
function percentile(values: number[], p: number): number {
|
| 803 |
+
if (values.length === 0) {
|
| 804 |
+
return 0;
|
| 805 |
+
}
|
| 806 |
+
const sorted = [...values].toSorted((a, b) => a - b);
|
| 807 |
+
const index = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
|
| 808 |
+
return sorted[index] ?? 0;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
function summarizeNumbers(values: number[]): SummaryStats {
|
| 812 |
+
const total = values.reduce((sum, value) => sum + value, 0);
|
| 813 |
+
const avg = values.length > 0 ? total / values.length : 0;
|
| 814 |
+
const min = values.length > 0 ? Math.min(...values) : 0;
|
| 815 |
+
const max = values.length > 0 ? Math.max(...values) : 0;
|
| 816 |
+
return {
|
| 817 |
+
avg,
|
| 818 |
+
p50: median(values),
|
| 819 |
+
p95: percentile(values, 95),
|
| 820 |
+
min,
|
| 821 |
+
max,
|
| 822 |
+
};
|
| 823 |
+
}
|
| 824 |
+
|
| 825 |
+
function summarizeSamples(samples: Sample[]): CaseSummary {
|
| 826 |
+
const durations = summarizeNumbers(samples.map((sample) => sample.ms));
|
| 827 |
+
const firstOutputValues = samples
|
| 828 |
+
.map((sample) => sample.firstOutputMs)
|
| 829 |
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
| 830 |
+
const rssValues = samples
|
| 831 |
+
.map((sample) => sample.maxRssMb)
|
| 832 |
+
.filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
| 833 |
+
return {
|
| 834 |
+
sampleCount: samples.length,
|
| 835 |
+
durationMs: durations,
|
| 836 |
+
firstOutputMs: firstOutputValues.length > 0 ? summarizeNumbers(firstOutputValues) : null,
|
| 837 |
+
maxRssMb: rssValues.length > 0 ? summarizeNumbers(rssValues) : null,
|
| 838 |
+
exitSummary: collectExitSummary(samples),
|
| 839 |
+
};
|
| 840 |
+
}
|
| 841 |
+
|
| 842 |
+
function formatMs(value: number): string {
|
| 843 |
+
return `${value.toFixed(1)}ms`;
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
function formatMb(value: number): string {
|
| 847 |
+
return `${value.toFixed(1)}MiB`;
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
function collectExitSummary(samples: Sample[]): string {
|
| 851 |
+
const buckets = new Map<string, number>();
|
| 852 |
+
for (const sample of samples) {
|
| 853 |
+
const key =
|
| 854 |
+
sample.signal != null
|
| 855 |
+
? `signal:${sample.signal}`
|
| 856 |
+
: `code:${sample.exitCode == null ? "null" : String(sample.exitCode)}`;
|
| 857 |
+
buckets.set(key, (buckets.get(key) ?? 0) + 1);
|
| 858 |
+
}
|
| 859 |
+
return [...buckets.entries()].map(([key, count]) => `${key}x${count}`).join(", ");
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
function buildConfigFixture(commandCase: CommandCase): Record<string, unknown> | null {
|
| 863 |
+
const usesSharedToken =
|
| 864 |
+
commandCase.id === "gatewayHealthJsonWarmState" ||
|
| 865 |
+
commandCase.id === "gatewayHealthJsonFreshState";
|
| 866 |
+
if (
|
| 867 |
+
commandCase.id !== "configGetGatewayPort" &&
|
| 868 |
+
commandCase.id !== "gatewayHealthJson" &&
|
| 869 |
+
!usesSharedToken &&
|
| 870 |
+
commandCase.id !== "health" &&
|
| 871 |
+
commandCase.id !== "healthJson"
|
| 872 |
+
) {
|
| 873 |
+
return null;
|
| 874 |
+
}
|
| 875 |
+
const port = parseGatewayPortEnv(process.env.OPENCLAW_GATEWAY_PORT);
|
| 876 |
+
return {
|
| 877 |
+
gateway: {
|
| 878 |
+
auth: { mode: usesSharedToken ? "token" : "none" },
|
| 879 |
+
bind: "loopback",
|
| 880 |
+
mode: "local",
|
| 881 |
+
port,
|
| 882 |
+
},
|
| 883 |
+
};
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
function buildRssHook(tmpDir: string): string {
|
| 887 |
+
const rssHookPath = path.join(tmpDir, "measure-rss.mjs");
|
| 888 |
+
writeFileSync(
|
| 889 |
+
rssHookPath,
|
| 890 |
+
[
|
| 891 |
+
"process.on('exit', () => {",
|
| 892 |
+
" const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;",
|
| 893 |
+
` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`,
|
| 894 |
+
"});",
|
| 895 |
+
"",
|
| 896 |
+
].join("\n"),
|
| 897 |
+
"utf8",
|
| 898 |
+
);
|
| 899 |
+
return rssHookPath;
|
| 900 |
+
}
|
| 901 |
+
|
| 902 |
+
function parseMaxRssMb(stderr: string): number | null {
|
| 903 |
+
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
| 904 |
+
const lastMatch = matches.at(-1);
|
| 905 |
+
if (!lastMatch?.[1]) {
|
| 906 |
+
return null;
|
| 907 |
+
}
|
| 908 |
+
return Number(lastMatch[1]) / 1024;
|
| 909 |
+
}
|
| 910 |
+
|
| 911 |
+
function buildRuntimeRssHook(tmpDir: string): string {
|
| 912 |
+
const rssHookPath = path.join(tmpDir, "measure-rss.mjs");
|
| 913 |
+
writeFileSync(
|
| 914 |
+
rssHookPath,
|
| 915 |
+
`import { writeFileSync, realpathSync } from "node:fs";
|
| 916 |
+
import { join } from "node:path";
|
| 917 |
+
import { isMainThread } from "node:worker_threads";
|
| 918 |
+
if (isMainThread) {
|
| 919 |
+
const { directory, entries, args } = JSON.parse(process.env.OPENCLAW_BENCH_MEMORY);
|
| 920 |
+
let entry;
|
| 921 |
+
try {
|
| 922 |
+
if (process.argv[1]) entry = realpathSync(process.argv[1]);
|
| 923 |
+
} catch (error) {
|
| 924 |
+
if (error.code !== "ENOENT") throw error;
|
| 925 |
+
}
|
| 926 |
+
const matchesArguments = JSON.stringify(process.argv.slice(2)) === JSON.stringify(args);
|
| 927 |
+
const record = {
|
| 928 |
+
pid: process.pid,
|
| 929 |
+
parentPid: process.ppid,
|
| 930 |
+
matchesArguments,
|
| 931 |
+
matchesInvocation: entries.includes(entry) && matchesArguments,
|
| 932 |
+
maxRssBytes: null,
|
| 933 |
+
};
|
| 934 |
+
const file = join(directory, String(process.pid) + ".json");
|
| 935 |
+
writeFileSync(file, JSON.stringify(record));
|
| 936 |
+
process.on("exit", () => {
|
| 937 |
+
record.maxRssBytes = process.resourceUsage().maxRSS * 1024;
|
| 938 |
+
writeFileSync(file, JSON.stringify(record));
|
| 939 |
+
});
|
| 940 |
+
}
|
| 941 |
+
`,
|
| 942 |
+
"utf8",
|
| 943 |
+
);
|
| 944 |
+
return rssHookPath;
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
function readSampleMemory(directory: string, entryPid: number | undefined): SampleMemory {
|
| 948 |
+
const memory: SampleMemory = { runtimePid: null, processes: [] };
|
| 949 |
+
try {
|
| 950 |
+
const observations: RssObservation[] = readdirSync(directory).map((file) => {
|
| 951 |
+
const value: unknown = JSON.parse(readFileSync(path.join(directory, file), "utf8"));
|
| 952 |
+
if (
|
| 953 |
+
typeof value !== "object" ||
|
| 954 |
+
value === null ||
|
| 955 |
+
!("pid" in value) ||
|
| 956 |
+
typeof value.pid !== "number" ||
|
| 957 |
+
!Number.isSafeInteger(value.pid) ||
|
| 958 |
+
value.pid <= 0 ||
|
| 959 |
+
file !== `${value.pid}.json` ||
|
| 960 |
+
!("parentPid" in value) ||
|
| 961 |
+
typeof value.parentPid !== "number" ||
|
| 962 |
+
!Number.isSafeInteger(value.parentPid) ||
|
| 963 |
+
value.parentPid < 0 ||
|
| 964 |
+
!("matchesArguments" in value) ||
|
| 965 |
+
typeof value.matchesArguments !== "boolean" ||
|
| 966 |
+
!("matchesInvocation" in value) ||
|
| 967 |
+
typeof value.matchesInvocation !== "boolean" ||
|
| 968 |
+
!("maxRssBytes" in value) ||
|
| 969 |
+
!(
|
| 970 |
+
value.maxRssBytes === null ||
|
| 971 |
+
(typeof value.maxRssBytes === "number" &&
|
| 972 |
+
Number.isSafeInteger(value.maxRssBytes) &&
|
| 973 |
+
value.maxRssBytes > 0)
|
| 974 |
+
)
|
| 975 |
+
) {
|
| 976 |
+
throw new Error("invalid process RSS observation");
|
| 977 |
+
}
|
| 978 |
+
return {
|
| 979 |
+
pid: value.pid,
|
| 980 |
+
parentPid: value.parentPid,
|
| 981 |
+
matchesArguments: value.matchesArguments,
|
| 982 |
+
matchesInvocation: value.matchesInvocation,
|
| 983 |
+
maxRssBytes: value.maxRssBytes,
|
| 984 |
+
};
|
| 985 |
+
});
|
| 986 |
+
memory.processes = observations.map((record) => ({
|
| 987 |
+
pid: record.pid,
|
| 988 |
+
parentPid: record.parentPid,
|
| 989 |
+
role: record.matchesInvocation ? "unresolved" : "auxiliary",
|
| 990 |
+
metricKind: "process-high-water-rss",
|
| 991 |
+
maxRssBytes: record.maxRssBytes,
|
| 992 |
+
}));
|
| 993 |
+
if (observations.some((record) => record.matchesArguments && !record.matchesInvocation)) {
|
| 994 |
+
throw new Error("unrecognized CLI entry with matching command arguments");
|
| 995 |
+
}
|
| 996 |
+
const invocation = observations.filter((record) => record.matchesInvocation);
|
| 997 |
+
const entryObservation = invocation.find((record) => record.pid === entryPid);
|
| 998 |
+
if (!entryObservation) {
|
| 999 |
+
throw new Error("missing CLI entry process identity");
|
| 1000 |
+
}
|
| 1001 |
+
let current: RssObservation = entryObservation;
|
| 1002 |
+
const lineage = new Set<number>();
|
| 1003 |
+
// Respawns preserve CLI argv and the preload. Follow the unique invocation
|
| 1004 |
+
// chain, not exit order, RSS magnitude, or platform-specific ready flags.
|
| 1005 |
+
while (true) {
|
| 1006 |
+
if (lineage.has(current.pid)) {
|
| 1007 |
+
throw new Error("cyclic CLI process identity");
|
| 1008 |
+
}
|
| 1009 |
+
lineage.add(current.pid);
|
| 1010 |
+
if (current.maxRssBytes === null) {
|
| 1011 |
+
throw new Error(`missing process high-water RSS for CLI PID ${current.pid}`);
|
| 1012 |
+
}
|
| 1013 |
+
const parentPid = current.pid;
|
| 1014 |
+
const children = invocation.filter((record) => record.parentPid === parentPid);
|
| 1015 |
+
if (children.length > 1) {
|
| 1016 |
+
throw new Error("ambiguous CLI runtime identity: multiple matching children");
|
| 1017 |
+
}
|
| 1018 |
+
const child = children[0];
|
| 1019 |
+
if (!child) {
|
| 1020 |
+
break;
|
| 1021 |
+
}
|
| 1022 |
+
current = child;
|
| 1023 |
+
}
|
| 1024 |
+
if (lineage.size !== invocation.length) {
|
| 1025 |
+
throw new Error("disconnected CLI runtime identity");
|
| 1026 |
+
}
|
| 1027 |
+
memory.runtimePid = current.pid;
|
| 1028 |
+
for (const record of memory.processes) {
|
| 1029 |
+
if (lineage.has(record.pid)) {
|
| 1030 |
+
record.role = record.pid === current.pid ? "runtime" : "launcher";
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
} catch (error) {
|
| 1034 |
+
memory.error = error instanceof Error ? error.message : String(error);
|
| 1035 |
+
}
|
| 1036 |
+
return memory;
|
| 1037 |
+
}
|
| 1038 |
+
|
| 1039 |
+
function memoryInvocationEntries(entry: string): string[] {
|
| 1040 |
+
const resolvedEntry = realpathSync(entry);
|
| 1041 |
+
const entries = [resolvedEntry];
|
| 1042 |
+
// The wrapper's compile-cache handoff can enter dist directly.
|
| 1043 |
+
if (path.basename(resolvedEntry) === "openclaw.mjs") {
|
| 1044 |
+
for (const name of ["entry.js", "entry.mjs"]) {
|
| 1045 |
+
try {
|
| 1046 |
+
entries.push(realpathSync(path.join(path.dirname(resolvedEntry), "dist", name)));
|
| 1047 |
+
} catch (error) {
|
| 1048 |
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
| 1049 |
+
throw error;
|
| 1050 |
+
}
|
| 1051 |
+
}
|
| 1052 |
+
}
|
| 1053 |
+
}
|
| 1054 |
+
return entries;
|
| 1055 |
+
}
|
| 1056 |
+
|
| 1057 |
+
function nodeImportSpecifierForPath(filePath: string): string {
|
| 1058 |
+
return pathToFileURL(filePath).href;
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
function buildCpuOrHeapFlags(options: { cpuProfDir?: string; heapProfDir?: string }): string[] {
|
| 1062 |
+
const flags: string[] = [];
|
| 1063 |
+
if (options.cpuProfDir) {
|
| 1064 |
+
flags.push("--cpu-prof", "--cpu-prof-dir", options.cpuProfDir);
|
| 1065 |
+
}
|
| 1066 |
+
if (options.heapProfDir) {
|
| 1067 |
+
flags.push("--heap-prof", "--heap-prof-dir", options.heapProfDir);
|
| 1068 |
+
}
|
| 1069 |
+
return flags;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
function appendLimited(current: string, chunk: Buffer | string, maxLength: number): string {
|
| 1073 |
+
const next = current + String(chunk);
|
| 1074 |
+
return next.length > maxLength ? next.slice(next.length - maxLength) : next;
|
| 1075 |
+
}
|
| 1076 |
+
|
| 1077 |
+
async function runSample(params: {
|
| 1078 |
+
entry: string;
|
| 1079 |
+
commandCase: CommandCase;
|
| 1080 |
+
timeoutMs: number;
|
| 1081 |
+
runtimeRss: boolean;
|
| 1082 |
+
cpuProfDir?: string;
|
| 1083 |
+
heapProfDir?: string;
|
| 1084 |
+
rssHookPath: string;
|
| 1085 |
+
runRoot?: string;
|
| 1086 |
+
}): Promise<Sample> {
|
| 1087 |
+
const transport = sampleTransport();
|
| 1088 |
+
const runRoot = params.runRoot ?? createSampleRoot(transport);
|
| 1089 |
+
const ownsRunRoot = params.runRoot == null;
|
| 1090 |
+
const stateDir = path.join(runRoot, ".openclaw");
|
| 1091 |
+
const configPath = path.join(stateDir, "openclaw.json");
|
| 1092 |
+
const configFixture = buildConfigFixture(params.commandCase);
|
| 1093 |
+
let rssHookPath = params.rssHookPath;
|
| 1094 |
+
if (transport) {
|
| 1095 |
+
sampleFilesystem(
|
| 1096 |
+
transport,
|
| 1097 |
+
"prepare",
|
| 1098 |
+
runRoot,
|
| 1099 |
+
configFixture,
|
| 1100 |
+
readFileSync(rssHookPath, "utf8"),
|
| 1101 |
+
);
|
| 1102 |
+
rssHookPath = path.join(runRoot, "measure-rss.mjs");
|
| 1103 |
+
} else if (configFixture) {
|
| 1104 |
+
mkdirSync(stateDir, { recursive: true });
|
| 1105 |
+
writeFileSync(configPath, `${JSON.stringify(configFixture, null, 2)}\n`, "utf8");
|
| 1106 |
+
}
|
| 1107 |
+
const nodeArgs = [
|
| 1108 |
+
...(transport
|
| 1109 |
+
? [
|
| 1110 |
+
"--import",
|
| 1111 |
+
`data:text/javascript,${encodeURIComponent(`process.chdir(${JSON.stringify(path.dirname(params.entry))});`)}`,
|
| 1112 |
+
]
|
| 1113 |
+
: []),
|
| 1114 |
+
"--import",
|
| 1115 |
+
nodeImportSpecifierForPath(rssHookPath),
|
| 1116 |
+
...buildCpuOrHeapFlags({
|
| 1117 |
+
cpuProfDir: params.cpuProfDir,
|
| 1118 |
+
heapProfDir: params.heapProfDir,
|
| 1119 |
+
}),
|
| 1120 |
+
params.entry,
|
| 1121 |
+
...params.commandCase.args,
|
| 1122 |
+
];
|
| 1123 |
+
const startedAt = new Date();
|
| 1124 |
+
const started = process.hrtime.bigint();
|
| 1125 |
+
let firstOutputMs: number | null = null;
|
| 1126 |
+
let stdout = "";
|
| 1127 |
+
let stderr = "";
|
| 1128 |
+
let settled = false;
|
| 1129 |
+
let timedOut = false;
|
| 1130 |
+
let forceKillAt: number | null = null;
|
| 1131 |
+
let forceKillTimer: ReturnType<typeof setTimeout> | null = null;
|
| 1132 |
+
const maxOutputLength = 32 * 1024 * 1024;
|
| 1133 |
+
const memoryDirectory = params.runtimeRss
|
| 1134 |
+
? mkdtempSync(path.join(path.dirname(params.rssHookPath), "sample-"))
|
| 1135 |
+
: undefined;
|
| 1136 |
+
|
| 1137 |
+
try {
|
| 1138 |
+
return await new Promise<Sample>((resolve) => {
|
| 1139 |
+
const sampleEnv = {
|
| 1140 |
+
HOME: runRoot,
|
| 1141 |
+
USERPROFILE: runRoot,
|
| 1142 |
+
OPENCLAW_HOME: runRoot,
|
| 1143 |
+
OPENCLAW_STATE_DIR: stateDir,
|
| 1144 |
+
OPENCLAW_CONFIG_PATH: configPath,
|
| 1145 |
+
OPENCLAW_HIDE_BANNER: "1",
|
| 1146 |
+
NO_COLOR: "1",
|
| 1147 |
+
FORCE_COLOR: "0",
|
| 1148 |
+
...(memoryDirectory
|
| 1149 |
+
? {
|
| 1150 |
+
OPENCLAW_BENCH_MEMORY: JSON.stringify({
|
| 1151 |
+
directory: memoryDirectory,
|
| 1152 |
+
entries: memoryInvocationEntries(params.entry),
|
| 1153 |
+
args: params.commandCase.args,
|
| 1154 |
+
}),
|
| 1155 |
+
}
|
| 1156 |
+
: {}),
|
| 1157 |
+
};
|
| 1158 |
+
const launch = transport
|
| 1159 |
+
? transportedCommand(
|
| 1160 |
+
transport,
|
| 1161 |
+
nodeArgs,
|
| 1162 |
+
{
|
| 1163 |
+
...sampleEnv,
|
| 1164 |
+
...(process.env.OPENCLAW_GATEWAY_TOKEN
|
| 1165 |
+
? { OPENCLAW_GATEWAY_TOKEN: process.env.OPENCLAW_GATEWAY_TOKEN }
|
| 1166 |
+
: {}),
|
| 1167 |
+
...(process.env.OPENCLAW_GATEWAY_PORT
|
| 1168 |
+
? { OPENCLAW_GATEWAY_PORT: process.env.OPENCLAW_GATEWAY_PORT }
|
| 1169 |
+
: {}),
|
| 1170 |
+
},
|
| 1171 |
+
params.timeoutMs,
|
| 1172 |
+
)
|
| 1173 |
+
: { command: process.execPath, args: nodeArgs };
|
| 1174 |
+
const proc = spawn(launch.command, launch.args, {
|
| 1175 |
+
cwd: process.cwd(),
|
| 1176 |
+
detached: process.platform !== "win32",
|
| 1177 |
+
env: transport ? { PATH: process.env.PATH } : { ...process.env, ...sampleEnv },
|
| 1178 |
+
stdio: ["ignore", "pipe", "pipe"],
|
| 1179 |
+
});
|
| 1180 |
+
|
| 1181 |
+
const finish = (sample: Omit<Sample, "ms" | "firstOutputMs" | "maxRssMb">) => {
|
| 1182 |
+
if (settled) {
|
| 1183 |
+
return;
|
| 1184 |
+
}
|
| 1185 |
+
settled = true;
|
| 1186 |
+
if (forceKillTimer) {
|
| 1187 |
+
clearTimeout(forceKillTimer);
|
| 1188 |
+
forceKillTimer = null;
|
| 1189 |
+
}
|
| 1190 |
+
const ms = Number(process.hrtime.bigint() - started) / 1e6;
|
| 1191 |
+
const memory = memoryDirectory ? readSampleMemory(memoryDirectory, proc.pid) : undefined;
|
| 1192 |
+
const runtimeRss = memory?.processes.find(
|
| 1193 |
+
(record) => record.role === "runtime",
|
| 1194 |
+
)?.maxRssBytes;
|
| 1195 |
+
resolve({
|
| 1196 |
+
ms,
|
| 1197 |
+
firstOutputMs,
|
| 1198 |
+
maxRssMb: memory
|
| 1199 |
+
? runtimeRss == null
|
| 1200 |
+
? null
|
| 1201 |
+
: runtimeRss / 1024 / 1024
|
| 1202 |
+
: parseMaxRssMb(stderr),
|
| 1203 |
+
...(memory ? { memory } : {}),
|
| 1204 |
+
startedAt: startedAt.toISOString(),
|
| 1205 |
+
endedAt: new Date().toISOString(),
|
| 1206 |
+
...(timedOut ? { timedOut } : {}),
|
| 1207 |
+
...sample,
|
| 1208 |
+
});
|
| 1209 |
+
};
|
| 1210 |
+
|
| 1211 |
+
const markFirstOutput = () => {
|
| 1212 |
+
if (firstOutputMs == null) {
|
| 1213 |
+
firstOutputMs = Number(process.hrtime.bigint() - started) / 1e6;
|
| 1214 |
+
}
|
| 1215 |
+
};
|
| 1216 |
+
|
| 1217 |
+
const timeout = setTimeout(() => {
|
| 1218 |
+
timedOut = true;
|
| 1219 |
+
signalSampleProcess(proc, "SIGTERM");
|
| 1220 |
+
forceKillAt = Date.now() + TIMEOUT_KILL_GRACE_MS;
|
| 1221 |
+
forceKillTimer = setTimeout(() => {
|
| 1222 |
+
signalSampleProcess(proc, "SIGKILL");
|
| 1223 |
+
}, TIMEOUT_KILL_GRACE_MS).unref?.();
|
| 1224 |
+
}, params.timeoutMs);
|
| 1225 |
+
timeout.unref?.();
|
| 1226 |
+
|
| 1227 |
+
proc.stdout?.on("data", (chunk) => {
|
| 1228 |
+
markFirstOutput();
|
| 1229 |
+
stdout = appendLimited(stdout, chunk, maxOutputLength);
|
| 1230 |
+
});
|
| 1231 |
+
proc.stderr?.on("data", (chunk) => {
|
| 1232 |
+
markFirstOutput();
|
| 1233 |
+
stderr = appendLimited(stderr, chunk, maxOutputLength);
|
| 1234 |
+
});
|
| 1235 |
+
proc.once("error", (error) => {
|
| 1236 |
+
clearTimeout(timeout);
|
| 1237 |
+
stderr = appendLimited(
|
| 1238 |
+
stderr,
|
| 1239 |
+
error instanceof Error ? error.message : String(error),
|
| 1240 |
+
maxOutputLength,
|
| 1241 |
+
);
|
| 1242 |
+
finish({
|
| 1243 |
+
exitCode: null,
|
| 1244 |
+
signal: null,
|
| 1245 |
+
stdoutTail: tailLines(stdout, 20),
|
| 1246 |
+
stderrTail: tailLines(stderr, 20),
|
| 1247 |
+
});
|
| 1248 |
+
});
|
| 1249 |
+
proc.once("close", (code, signal) => {
|
| 1250 |
+
clearTimeout(timeout);
|
| 1251 |
+
const complete = () =>
|
| 1252 |
+
finish({
|
| 1253 |
+
exitCode: code,
|
| 1254 |
+
signal,
|
| 1255 |
+
...(code === 0 && signal == null
|
| 1256 |
+
? {}
|
| 1257 |
+
: {
|
| 1258 |
+
stdoutTail: tailLines(stdout, 20),
|
| 1259 |
+
stderrTail: tailLines(stderr, 20),
|
| 1260 |
+
}),
|
| 1261 |
+
});
|
| 1262 |
+
if (timedOut && isSampleProcessGroupAlive(proc)) {
|
| 1263 |
+
void finishAfterTimeoutCleanup({
|
| 1264 |
+
complete,
|
| 1265 |
+
forceKillAt,
|
| 1266 |
+
proc,
|
| 1267 |
+
});
|
| 1268 |
+
return;
|
| 1269 |
+
}
|
| 1270 |
+
complete();
|
| 1271 |
+
});
|
| 1272 |
+
});
|
| 1273 |
+
} finally {
|
| 1274 |
+
if (memoryDirectory) {
|
| 1275 |
+
rmSync(memoryDirectory, { recursive: true, force: true });
|
| 1276 |
+
}
|
| 1277 |
+
if (ownsRunRoot) {
|
| 1278 |
+
removeSampleRoot(runRoot, transport);
|
| 1279 |
+
}
|
| 1280 |
+
}
|
| 1281 |
+
}
|
| 1282 |
+
|
| 1283 |
+
async function finishAfterTimeoutCleanup(params: {
|
| 1284 |
+
complete: () => void;
|
| 1285 |
+
forceKillAt: number | null;
|
| 1286 |
+
proc: ReturnType<typeof spawn>;
|
| 1287 |
+
}): Promise<void> {
|
| 1288 |
+
const graceRemainingMs =
|
| 1289 |
+
params.forceKillAt === null
|
| 1290 |
+
? TIMEOUT_KILL_GRACE_MS
|
| 1291 |
+
: Math.max(0, params.forceKillAt - Date.now());
|
| 1292 |
+
if (graceRemainingMs > 0) {
|
| 1293 |
+
await waitForSampleProcessGroupExit(params.proc, graceRemainingMs);
|
| 1294 |
+
}
|
| 1295 |
+
if (isSampleProcessGroupAlive(params.proc)) {
|
| 1296 |
+
signalSampleProcess(params.proc, "SIGKILL");
|
| 1297 |
+
}
|
| 1298 |
+
await waitForSampleProcessGroupExit(params.proc, TIMEOUT_KILL_GRACE_MS);
|
| 1299 |
+
params.complete();
|
| 1300 |
+
}
|
| 1301 |
+
|
| 1302 |
+
function signalSampleProcess(proc: ReturnType<typeof spawn>, signal: NodeJS.Signals): void {
|
| 1303 |
+
if (!proc.pid) {
|
| 1304 |
+
return;
|
| 1305 |
+
}
|
| 1306 |
+
const handleSignalError = (error: unknown) => {
|
| 1307 |
+
const code = (error as NodeJS.ErrnoException | undefined)?.code;
|
| 1308 |
+
if (code !== "ESRCH" && code !== "EPERM") {
|
| 1309 |
+
throw error;
|
| 1310 |
+
}
|
| 1311 |
+
};
|
| 1312 |
+
terminateManagedChild(proc, signal, {
|
| 1313 |
+
onChildSignalError: handleSignalError,
|
| 1314 |
+
onProcessGroupSignalError: handleSignalError,
|
| 1315 |
+
processGroupFallback: "never",
|
| 1316 |
+
useWindowsTaskkill: false,
|
| 1317 |
+
});
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
function isSampleProcessGroupAlive(proc: ReturnType<typeof spawn>): boolean {
|
| 1321 |
+
return inspectManagedProcessGroup(proc, { errorPolicy: "alive-on-eperm" }) === "live";
|
| 1322 |
+
}
|
| 1323 |
+
|
| 1324 |
+
function waitForSampleProcessGroupExit(
|
| 1325 |
+
proc: ReturnType<typeof spawn>,
|
| 1326 |
+
timeoutMs: number,
|
| 1327 |
+
): Promise<boolean> {
|
| 1328 |
+
return waitForManagedProcessGroupExit(proc, timeoutMs, { errorPolicy: "alive-on-eperm" });
|
| 1329 |
+
}
|
| 1330 |
+
|
| 1331 |
+
async function runCase(params: {
|
| 1332 |
+
entry: string;
|
| 1333 |
+
commandCase: CommandCase;
|
| 1334 |
+
runs: number;
|
| 1335 |
+
warmup: number;
|
| 1336 |
+
timeoutMs: number;
|
| 1337 |
+
runtimeRss: boolean;
|
| 1338 |
+
cpuProfDir?: string;
|
| 1339 |
+
heapProfDir?: string;
|
| 1340 |
+
rssHookPath: string;
|
| 1341 |
+
}): Promise<CaseRuns> {
|
| 1342 |
+
const warmupSamples: Sample[] = [];
|
| 1343 |
+
const samples: Sample[] = [];
|
| 1344 |
+
const totalRuns = params.warmup + params.runs;
|
| 1345 |
+
const transport = sampleTransport();
|
| 1346 |
+
const caseRunRoot =
|
| 1347 |
+
params.commandCase.stateScope === "case" ? createSampleRoot(transport) : undefined;
|
| 1348 |
+
try {
|
| 1349 |
+
for (let i = 0; i < totalRuns; i += 1) {
|
| 1350 |
+
const sample = await runSample({ ...params, runRoot: caseRunRoot });
|
| 1351 |
+
if (i < params.warmup) {
|
| 1352 |
+
warmupSamples.push(sample);
|
| 1353 |
+
continue;
|
| 1354 |
+
}
|
| 1355 |
+
samples.push(sample);
|
| 1356 |
+
}
|
| 1357 |
+
return { warmupSamples, samples };
|
| 1358 |
+
} finally {
|
| 1359 |
+
if (caseRunRoot) {
|
| 1360 |
+
removeSampleRoot(caseRunRoot, transport);
|
| 1361 |
+
}
|
| 1362 |
+
}
|
| 1363 |
+
}
|
| 1364 |
+
|
| 1365 |
+
function tailLines(value: string, maxLines: number): string {
|
| 1366 |
+
return value.split(/\r?\n/).filter(Boolean).slice(-maxLines).join("\n");
|
| 1367 |
+
}
|
| 1368 |
+
|
| 1369 |
+
function printSuite(result: SuiteResult): void {
|
| 1370 |
+
console.log(`Entry: ${result.entry}`);
|
| 1371 |
+
console.log(`RSS metric: ${cliStartupMemoryMetric(result)}`);
|
| 1372 |
+
for (const commandCase of result.cases) {
|
| 1373 |
+
const { durationMs, firstOutputMs, maxRssMb, exitSummary } = commandCase.summary;
|
| 1374 |
+
const rssSummary =
|
| 1375 |
+
maxRssMb == null
|
| 1376 |
+
? "rss=n/a"
|
| 1377 |
+
: `rss(avg=${formatMb(maxRssMb.avg)} p50=${formatMb(maxRssMb.p50)} p95=${formatMb(maxRssMb.p95)})`;
|
| 1378 |
+
const firstOutputSummary =
|
| 1379 |
+
firstOutputMs == null
|
| 1380 |
+
? "first-output=n/a"
|
| 1381 |
+
: `first-output(avg=${formatMs(firstOutputMs.avg)} p50=${formatMs(
|
| 1382 |
+
firstOutputMs.p50,
|
| 1383 |
+
)} p95=${formatMs(firstOutputMs.p95)})`;
|
| 1384 |
+
console.log(
|
| 1385 |
+
`${commandCase.name.padEnd(24)} avg=${formatMs(durationMs.avg)} p50=${formatMs(
|
| 1386 |
+
durationMs.p50,
|
| 1387 |
+
)} p95=${formatMs(durationMs.p95)} min=${formatMs(durationMs.min)} max=${formatMs(
|
| 1388 |
+
durationMs.max,
|
| 1389 |
+
)} ${firstOutputSummary} ${rssSummary} exits=[${exitSummary}]`,
|
| 1390 |
+
);
|
| 1391 |
+
}
|
| 1392 |
+
console.log("");
|
| 1393 |
+
}
|
| 1394 |
+
|
| 1395 |
+
function printDelta(primary: SuiteResult, secondary: SuiteResult): void {
|
| 1396 |
+
const deltas = buildCaseDeltas(primary, secondary);
|
| 1397 |
+
console.log("Delta (secondary - primary, avg)");
|
| 1398 |
+
for (const delta of deltas) {
|
| 1399 |
+
const durationDelta = delta.durationAvgDeltaMs;
|
| 1400 |
+
const durationPct = delta.durationAvgDeltaPct;
|
| 1401 |
+
const durationSign = durationDelta > 0 ? "+" : "";
|
| 1402 |
+
let line = `${delta.name.padEnd(24)} ${durationSign}${formatMs(durationDelta)} (${durationSign}${durationPct.toFixed(1)}%)`;
|
| 1403 |
+
if (delta.maxRssAvgDeltaMb != null && delta.maxRssAvgDeltaPct != null) {
|
| 1404 |
+
const rssDelta = delta.maxRssAvgDeltaMb;
|
| 1405 |
+
const rssPct = delta.maxRssAvgDeltaPct;
|
| 1406 |
+
const rssSign = rssDelta > 0 ? "+" : "";
|
| 1407 |
+
line += ` rss ${rssSign}${formatMb(rssDelta)} (${rssSign}${rssPct.toFixed(1)}%)`;
|
| 1408 |
+
}
|
| 1409 |
+
console.log(line);
|
| 1410 |
+
}
|
| 1411 |
+
}
|
| 1412 |
+
|
| 1413 |
+
function buildCaseDeltas(primary: SuiteResult, secondary: SuiteResult): CaseDelta[] {
|
| 1414 |
+
assertCompatibleCliStartupExecutionModes(primary, secondary);
|
| 1415 |
+
assertCompatibleCliStartupMemoryMetrics(primary, secondary);
|
| 1416 |
+
const primaryById = new Map(primary.cases.map((commandCase) => [commandCase.id, commandCase]));
|
| 1417 |
+
const deltas: CaseDelta[] = [];
|
| 1418 |
+
for (const commandCase of secondary.cases) {
|
| 1419 |
+
const baseline = primaryById.get(commandCase.id);
|
| 1420 |
+
if (!baseline) {
|
| 1421 |
+
continue;
|
| 1422 |
+
}
|
| 1423 |
+
const durationDelta = commandCase.summary.durationMs.avg - baseline.summary.durationMs.avg;
|
| 1424 |
+
const durationPct =
|
| 1425 |
+
baseline.summary.durationMs.avg > 0
|
| 1426 |
+
? (durationDelta / baseline.summary.durationMs.avg) * 100
|
| 1427 |
+
: 0;
|
| 1428 |
+
const rssDelta =
|
| 1429 |
+
baseline.summary.maxRssMb && commandCase.summary.maxRssMb
|
| 1430 |
+
? commandCase.summary.maxRssMb.avg - baseline.summary.maxRssMb.avg
|
| 1431 |
+
: null;
|
| 1432 |
+
const rssPct =
|
| 1433 |
+
rssDelta != null && baseline.summary.maxRssMb && baseline.summary.maxRssMb.avg > 0
|
| 1434 |
+
? (rssDelta / baseline.summary.maxRssMb.avg) * 100
|
| 1435 |
+
: null;
|
| 1436 |
+
deltas.push({
|
| 1437 |
+
id: commandCase.id,
|
| 1438 |
+
name: commandCase.name,
|
| 1439 |
+
durationAvgDeltaMs: durationDelta,
|
| 1440 |
+
durationAvgDeltaPct: durationPct,
|
| 1441 |
+
maxRssAvgDeltaMb: rssDelta,
|
| 1442 |
+
maxRssAvgDeltaPct: rssPct,
|
| 1443 |
+
});
|
| 1444 |
+
}
|
| 1445 |
+
return deltas;
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
export function collectFailedSamples(result: SuiteResult): string[] {
|
| 1449 |
+
const failures: string[] = [];
|
| 1450 |
+
for (const commandCase of result.cases) {
|
| 1451 |
+
if (commandCase.samples.length === 0) {
|
| 1452 |
+
failures.push(`${result.entry} ${commandCase.id}: no measured samples`);
|
| 1453 |
+
}
|
| 1454 |
+
for (const [sampleKind, samples] of [
|
| 1455 |
+
["warmup", commandCase.warmupSamples ?? []],
|
| 1456 |
+
["sample", commandCase.samples],
|
| 1457 |
+
] as const) {
|
| 1458 |
+
for (const [sampleIndex, sample] of samples.entries()) {
|
| 1459 |
+
const label = `${result.entry} ${commandCase.id} ${sampleKind} ${sampleIndex + 1}`;
|
| 1460 |
+
const expectedExitCodes = new Set(commandCase.expectedExitCodes ?? [0]);
|
| 1461 |
+
if (sample.timedOut === true) {
|
| 1462 |
+
failures.push(`${label}: timed out`);
|
| 1463 |
+
} else if (sample.signal !== null) {
|
| 1464 |
+
failures.push(`${label}: exited via signal ${sample.signal}`);
|
| 1465 |
+
} else if (!expectedExitCodes.has(sample.exitCode ?? -1)) {
|
| 1466 |
+
failures.push(`${label}: exited with code ${String(sample.exitCode)}`);
|
| 1467 |
+
} else if (sample.maxRssMb === null) {
|
| 1468 |
+
failures.push(
|
| 1469 |
+
`${label}: did not report max RSS${sample.memory?.error ? ` (${sample.memory.error})` : ""}`,
|
| 1470 |
+
);
|
| 1471 |
+
} else if (sample.exitCode !== 0) {
|
| 1472 |
+
const output = `${sample.stdoutTail ?? ""}\n${sample.stderrTail ?? ""}`;
|
| 1473 |
+
const missing = (commandCase.expectedNonzeroOutputIncludes ?? []).filter(
|
| 1474 |
+
(snippet) => !output.includes(snippet),
|
| 1475 |
+
);
|
| 1476 |
+
if (missing.length > 0) {
|
| 1477 |
+
failures.push(
|
| 1478 |
+
`${label}: exited with expected code ${String(
|
| 1479 |
+
sample.exitCode,
|
| 1480 |
+
)} but output did not match expected clean-state markers (${missing.join(", ")})`,
|
| 1481 |
+
);
|
| 1482 |
+
}
|
| 1483 |
+
}
|
| 1484 |
+
}
|
| 1485 |
+
}
|
| 1486 |
+
}
|
| 1487 |
+
return failures;
|
| 1488 |
+
}
|
| 1489 |
+
|
| 1490 |
+
async function buildSuiteResult(params: {
|
| 1491 |
+
entry: string;
|
| 1492 |
+
executionMode: CliStartupExecutionMode;
|
| 1493 |
+
options: CliOptions;
|
| 1494 |
+
rssHookPath: string;
|
| 1495 |
+
}): Promise<SuiteResult> {
|
| 1496 |
+
const cases = [];
|
| 1497 |
+
for (const commandCase of params.options.cases) {
|
| 1498 |
+
const { warmupSamples, samples } = await runCase({
|
| 1499 |
+
entry: params.entry,
|
| 1500 |
+
commandCase,
|
| 1501 |
+
runs: params.options.runs,
|
| 1502 |
+
warmup: params.options.warmup,
|
| 1503 |
+
timeoutMs: params.options.timeoutMs,
|
| 1504 |
+
runtimeRss: params.options.runtimeRss,
|
| 1505 |
+
cpuProfDir: params.options.cpuProfDir,
|
| 1506 |
+
heapProfDir: params.options.heapProfDir,
|
| 1507 |
+
rssHookPath: params.rssHookPath,
|
| 1508 |
+
});
|
| 1509 |
+
cases.push({
|
| 1510 |
+
id: commandCase.id,
|
| 1511 |
+
name: commandCase.name,
|
| 1512 |
+
args: commandCase.args,
|
| 1513 |
+
...(commandCase.expectedExitCodes && commandCase.expectedExitCodes.some((code) => code !== 0)
|
| 1514 |
+
? { expectedExitCodes: [...commandCase.expectedExitCodes] }
|
| 1515 |
+
: {}),
|
| 1516 |
+
...(commandCase.expectedNonzeroOutputIncludes
|
| 1517 |
+
? { expectedNonzeroOutputIncludes: [...commandCase.expectedNonzeroOutputIncludes] }
|
| 1518 |
+
: {}),
|
| 1519 |
+
contract:
|
| 1520 |
+
commandCase.firstOutputBudgetMs != null || commandCase.exitBudgetMs != null
|
| 1521 |
+
? {
|
| 1522 |
+
firstOutputBudgetMs: commandCase.firstOutputBudgetMs ?? null,
|
| 1523 |
+
exitBudgetMs: commandCase.exitBudgetMs ?? null,
|
| 1524 |
+
}
|
| 1525 |
+
: null,
|
| 1526 |
+
warmupSamples,
|
| 1527 |
+
samples,
|
| 1528 |
+
summary: summarizeSamples(samples),
|
| 1529 |
+
});
|
| 1530 |
+
}
|
| 1531 |
+
return {
|
| 1532 |
+
entry: params.entry,
|
| 1533 |
+
executionMode: params.executionMode,
|
| 1534 |
+
...(params.options.runtimeRss ? { memoryMetric: CLI_RUNTIME_MEMORY_METRIC } : {}),
|
| 1535 |
+
cases,
|
| 1536 |
+
};
|
| 1537 |
+
}
|
| 1538 |
+
|
| 1539 |
+
function parseOptions(): CliOptions {
|
| 1540 |
+
const presets = parsePresets(parseFlagValue("--preset"));
|
| 1541 |
+
const cases = resolveCases({
|
| 1542 |
+
presets,
|
| 1543 |
+
caseIds: parseRepeatableFlag("--case"),
|
| 1544 |
+
});
|
| 1545 |
+
return {
|
| 1546 |
+
cases,
|
| 1547 |
+
compareBaseline: parseFlagValue("--compare-baseline"),
|
| 1548 |
+
compareCandidate: parseFlagValue("--compare-candidate"),
|
| 1549 |
+
entryPrimary: parseFlagValue("--entry-primary") ?? parseFlagValue("--entry") ?? DEFAULT_ENTRY,
|
| 1550 |
+
entrySecondary: parseFlagValue("--entry-secondary"),
|
| 1551 |
+
runs: parsePositiveInt(parseFlagValue("--runs"), DEFAULT_RUNS, "--runs"),
|
| 1552 |
+
warmup: parseNonNegativeInt(parseFlagValue("--warmup"), DEFAULT_WARMUP, "--warmup"),
|
| 1553 |
+
timeoutMs: parsePositiveInt(parseFlagValue("--timeout-ms"), DEFAULT_TIMEOUT_MS, "--timeout-ms"),
|
| 1554 |
+
runtimeRss: hasFlag("--runtime-rss"),
|
| 1555 |
+
json: hasFlag("--json"),
|
| 1556 |
+
output: parseFlagValue("--output"),
|
| 1557 |
+
cpuProfDir: parseFlagValue("--cpu-prof-dir"),
|
| 1558 |
+
heapProfDir: parseFlagValue("--heap-prof-dir"),
|
| 1559 |
+
};
|
| 1560 |
+
}
|
| 1561 |
+
|
| 1562 |
+
function printUsage(): void {
|
| 1563 |
+
console.log(`OpenClaw CLI benchmark
|
| 1564 |
+
|
| 1565 |
+
Usage:
|
| 1566 |
+
pnpm tsx scripts/bench-cli-startup.ts [options]
|
| 1567 |
+
|
| 1568 |
+
Options:
|
| 1569 |
+
--preset <startup|real|response|all>
|
| 1570 |
+
Command preset to run (default: startup)
|
| 1571 |
+
--case <id> Specific case id to run; repeatable
|
| 1572 |
+
--entry <path> Primary entry file (default: openclaw.mjs)
|
| 1573 |
+
--entry-secondary <path> Secondary entry file for avg delta comparison
|
| 1574 |
+
--runs <n> Measured runs per case (default: ${DEFAULT_RUNS})
|
| 1575 |
+
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
|
| 1576 |
+
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
|
| 1577 |
+
--output <path> Write machine-readable JSON to a file
|
| 1578 |
+
--compare-baseline <path> Read a saved JSON report as the baseline
|
| 1579 |
+
--compare-candidate <path> Read a saved JSON report as the candidate and print deltas
|
| 1580 |
+
--cpu-prof-dir <dir> Write V8 CPU profiles for each run
|
| 1581 |
+
--heap-prof-dir <dir> Write V8 heap profiles for each run
|
| 1582 |
+
--runtime-rss Attribute RSS to the CLI runtime (default: legacy last marker)
|
| 1583 |
+
--json Emit machine-readable JSON
|
| 1584 |
+
--help Show this text
|
| 1585 |
+
|
| 1586 |
+
Case ids:
|
| 1587 |
+
${COMMAND_CASES.map((commandCase) => `${commandCase.id} (${commandCase.name})`).join("\n ")}
|
| 1588 |
+
`);
|
| 1589 |
+
}
|
| 1590 |
+
|
| 1591 |
+
function readBenchmarkReport(filePath: string): BenchmarkReport {
|
| 1592 |
+
return JSON.parse(readFileSync(filePath, "utf8")) as BenchmarkReport;
|
| 1593 |
+
}
|
| 1594 |
+
|
| 1595 |
+
function writeJsonOutput(filePath: string, value: unknown): void {
|
| 1596 |
+
mkdirSync(path.dirname(filePath), { recursive: true });
|
| 1597 |
+
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
| 1598 |
+
}
|
| 1599 |
+
|
| 1600 |
+
function readBenchmarkComparison(
|
| 1601 |
+
baselinePath: string,
|
| 1602 |
+
candidatePath: string,
|
| 1603 |
+
): BenchmarkComparisonResult {
|
| 1604 |
+
const baseline = readBenchmarkReport(baselinePath);
|
| 1605 |
+
const candidate = readBenchmarkReport(candidatePath);
|
| 1606 |
+
return {
|
| 1607 |
+
baseline: baseline.primary,
|
| 1608 |
+
candidate: candidate.primary,
|
| 1609 |
+
comparison: {
|
| 1610 |
+
baseline: baselinePath,
|
| 1611 |
+
candidate: candidatePath,
|
| 1612 |
+
deltas: buildCaseDeltas(baseline.primary, candidate.primary),
|
| 1613 |
+
},
|
| 1614 |
+
};
|
| 1615 |
+
}
|
| 1616 |
+
|
| 1617 |
+
async function main(): Promise<void> {
|
| 1618 |
+
validateCliArgs();
|
| 1619 |
+
if (hasFlag("--help")) {
|
| 1620 |
+
printUsage();
|
| 1621 |
+
return;
|
| 1622 |
+
}
|
| 1623 |
+
|
| 1624 |
+
const options = parseOptions();
|
| 1625 |
+
const transport = sampleTransport();
|
| 1626 |
+
if (transport && options.runtimeRss) {
|
| 1627 |
+
throw new Error("Cross-user runtime RSS sampling is not supported");
|
| 1628 |
+
}
|
| 1629 |
+
if (transport && (options.cpuProfDir || options.heapProfDir)) {
|
| 1630 |
+
throw new Error(
|
| 1631 |
+
"Cross-user CLI profiles must be collected through the SUT diagnostic exporter",
|
| 1632 |
+
);
|
| 1633 |
+
}
|
| 1634 |
+
if (options.compareBaseline || options.compareCandidate) {
|
| 1635 |
+
if (!options.compareBaseline || !options.compareCandidate) {
|
| 1636 |
+
throw new Error("--compare-baseline and --compare-candidate must be provided together");
|
| 1637 |
+
}
|
| 1638 |
+
const { baseline, candidate, comparison } = readBenchmarkComparison(
|
| 1639 |
+
options.compareBaseline,
|
| 1640 |
+
options.compareCandidate,
|
| 1641 |
+
);
|
| 1642 |
+
if (options.output) {
|
| 1643 |
+
writeJsonOutput(options.output, comparison);
|
| 1644 |
+
}
|
| 1645 |
+
if (options.json) {
|
| 1646 |
+
console.log(JSON.stringify(comparison, null, 2));
|
| 1647 |
+
return;
|
| 1648 |
+
}
|
| 1649 |
+
printDelta(baseline, candidate);
|
| 1650 |
+
return;
|
| 1651 |
+
}
|
| 1652 |
+
const tmpDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-"));
|
| 1653 |
+
const rssHookPath = options.runtimeRss ? buildRuntimeRssHook(tmpDir) : buildRssHook(tmpDir);
|
| 1654 |
+
try {
|
| 1655 |
+
const primary = await buildSuiteResult({
|
| 1656 |
+
entry: options.entryPrimary,
|
| 1657 |
+
executionMode: transport ? "transport" : "native",
|
| 1658 |
+
options,
|
| 1659 |
+
rssHookPath,
|
| 1660 |
+
});
|
| 1661 |
+
const secondary = options.entrySecondary
|
| 1662 |
+
? await buildSuiteResult({
|
| 1663 |
+
entry: options.entrySecondary,
|
| 1664 |
+
executionMode: transport ? "transport" : "native",
|
| 1665 |
+
options,
|
| 1666 |
+
rssHookPath,
|
| 1667 |
+
})
|
| 1668 |
+
: undefined;
|
| 1669 |
+
|
| 1670 |
+
const report = {
|
| 1671 |
+
node: process.version,
|
| 1672 |
+
runs: options.runs,
|
| 1673 |
+
warmup: options.warmup,
|
| 1674 |
+
timeoutMs: options.timeoutMs,
|
| 1675 |
+
cpuProfDir: options.cpuProfDir ?? null,
|
| 1676 |
+
heapProfDir: options.heapProfDir ?? null,
|
| 1677 |
+
primary,
|
| 1678 |
+
secondary: secondary ?? null,
|
| 1679 |
+
};
|
| 1680 |
+
const failures = [
|
| 1681 |
+
...collectFailedSamples(primary),
|
| 1682 |
+
...(secondary ? collectFailedSamples(secondary) : []),
|
| 1683 |
+
];
|
| 1684 |
+
|
| 1685 |
+
if (options.output) {
|
| 1686 |
+
writeJsonOutput(options.output, report);
|
| 1687 |
+
}
|
| 1688 |
+
|
| 1689 |
+
if (options.json) {
|
| 1690 |
+
console.log(JSON.stringify(report, null, 2));
|
| 1691 |
+
if (failures.length > 0) {
|
| 1692 |
+
process.exitCode = 1;
|
| 1693 |
+
for (const failure of failures) {
|
| 1694 |
+
console.error(`[startup-bench] ${failure}`);
|
| 1695 |
+
}
|
| 1696 |
+
}
|
| 1697 |
+
return;
|
| 1698 |
+
}
|
| 1699 |
+
|
| 1700 |
+
console.log(`Node: ${process.version}`);
|
| 1701 |
+
console.log(`Runs per case: ${options.runs}`);
|
| 1702 |
+
console.log(`Warmup runs per case: ${options.warmup}`);
|
| 1703 |
+
console.log(`Timeout: ${options.timeoutMs}ms`);
|
| 1704 |
+
if (options.cpuProfDir) {
|
| 1705 |
+
console.log(`CPU profiles: ${options.cpuProfDir}`);
|
| 1706 |
+
}
|
| 1707 |
+
if (options.heapProfDir) {
|
| 1708 |
+
console.log(`Heap profiles: ${options.heapProfDir}`);
|
| 1709 |
+
}
|
| 1710 |
+
console.log("");
|
| 1711 |
+
|
| 1712 |
+
console.log("Primary entry");
|
| 1713 |
+
printSuite(primary);
|
| 1714 |
+
if (secondary) {
|
| 1715 |
+
console.log("Secondary entry");
|
| 1716 |
+
printSuite(secondary);
|
| 1717 |
+
printDelta(primary, secondary);
|
| 1718 |
+
}
|
| 1719 |
+
|
| 1720 |
+
if (failures.length > 0) {
|
| 1721 |
+
process.exitCode = 1;
|
| 1722 |
+
console.error("\nFailed startup benchmark samples:");
|
| 1723 |
+
for (const failure of failures) {
|
| 1724 |
+
console.error(`- ${failure}`);
|
| 1725 |
+
}
|
| 1726 |
+
}
|
| 1727 |
+
} finally {
|
| 1728 |
+
rmSync(tmpDir, { recursive: true, force: true });
|
| 1729 |
+
}
|
| 1730 |
+
}
|
| 1731 |
+
|
| 1732 |
+
export const testing = {
|
| 1733 |
+
buildConfigFixture,
|
| 1734 |
+
collectFailedSamples,
|
| 1735 |
+
nodeImportSpecifierForPath,
|
| 1736 |
+
parseGatewayPortEnv,
|
| 1737 |
+
parseNonNegativeInt,
|
| 1738 |
+
parsePositiveInt,
|
| 1739 |
+
validateCliArgs,
|
| 1740 |
+
};
|
| 1741 |
+
|
| 1742 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 1743 |
+
await main().catch((error: unknown) => {
|
| 1744 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 1745 |
+
process.exit(1);
|
| 1746 |
+
});
|
| 1747 |
+
}
|
scripts/bench-gateway-concurrency.ts
ADDED
|
@@ -0,0 +1,2569 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { spawn, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
| 2 |
+
// Bench Gateway Concurrency script measures gateway probes during synthetic streaming turns.
|
| 3 |
+
import { randomUUID } from "node:crypto";
|
| 4 |
+
import {
|
| 5 |
+
copyFileSync,
|
| 6 |
+
cpSync,
|
| 7 |
+
existsSync,
|
| 8 |
+
mkdirSync,
|
| 9 |
+
mkdtempSync,
|
| 10 |
+
readFileSync,
|
| 11 |
+
rmSync,
|
| 12 |
+
writeFileSync,
|
| 13 |
+
} from "node:fs";
|
| 14 |
+
import { request } from "node:http";
|
| 15 |
+
import { tmpdir } from "node:os";
|
| 16 |
+
import path from "node:path";
|
| 17 |
+
import { performance } from "node:perf_hooks";
|
| 18 |
+
import { pathToFileURL } from "node:url";
|
| 19 |
+
import type { ModelsListResult } from "../packages/gateway-protocol/src/schema/agents-models-skills.js";
|
| 20 |
+
import { asFiniteNumber } from "../packages/normalization-core/src/number-coercion.ts";
|
| 21 |
+
import { isRecord } from "../packages/normalization-core/src/record-coerce.ts";
|
| 22 |
+
import { sliceUtf16Safe } from "../packages/normalization-core/src/utf16-slice.ts";
|
| 23 |
+
import type { SessionsListResult } from "../src/gateway/session-utils.types.js";
|
| 24 |
+
import { applyMockOpenAiModelConfig } from "./e2e/lib/fixtures/mock-openai-config.mjs";
|
| 25 |
+
import {
|
| 26 |
+
startGatewayBrowserProbe,
|
| 27 |
+
type BrowserSessionClick,
|
| 28 |
+
type BrowserSessionTarget,
|
| 29 |
+
} from "./lib/gateway-bench-browser.ts";
|
| 30 |
+
import { delay, stopChild } from "./lib/gateway-bench-child.ts";
|
| 31 |
+
import {
|
| 32 |
+
type GatewayMemorySample,
|
| 33 |
+
type GatewayRpc,
|
| 34 |
+
getFreePort,
|
| 35 |
+
readGatewayMemory,
|
| 36 |
+
readProcessRssMb,
|
| 37 |
+
} from "./lib/gateway-bench-probes.ts";
|
| 38 |
+
import {
|
| 39 |
+
controlGatewayProfile,
|
| 40 |
+
measureGatewayCpuUsage,
|
| 41 |
+
readGatewayCpuProfile,
|
| 42 |
+
readGatewayCpuUsage,
|
| 43 |
+
readGatewayHeapProfile,
|
| 44 |
+
type GatewayHeapProfile,
|
| 45 |
+
type GatewayCpuProfile,
|
| 46 |
+
type GatewayCpuUsage,
|
| 47 |
+
} from "./lib/gateway-bench-profile.ts";
|
| 48 |
+
import {
|
| 49 |
+
BASE_GATEWAY_BENCH_CONFIG,
|
| 50 |
+
buildGatewayBenchChildArgs,
|
| 51 |
+
CliArgumentError,
|
| 52 |
+
createGatewayBenchEnv,
|
| 53 |
+
hasFlag,
|
| 54 |
+
hasHelpFlag,
|
| 55 |
+
parseFlagValue,
|
| 56 |
+
parseNonNegativeInt,
|
| 57 |
+
parsePositiveInt,
|
| 58 |
+
resolveEntry,
|
| 59 |
+
resolveOutputPath,
|
| 60 |
+
validateCliArgs,
|
| 61 |
+
waitForInitialProbe,
|
| 62 |
+
writeGatewayBenchConfig,
|
| 63 |
+
writePluginFixtures,
|
| 64 |
+
} from "./lib/gateway-bench-runtime.ts";
|
| 65 |
+
import { createGatewayWsClient } from "./lib/gateway-ws-client.ts";
|
| 66 |
+
|
| 67 |
+
type MetricSummary = {
|
| 68 |
+
count: number;
|
| 69 |
+
max: number;
|
| 70 |
+
p50: number;
|
| 71 |
+
p95: number;
|
| 72 |
+
p99: number;
|
| 73 |
+
};
|
| 74 |
+
|
| 75 |
+
type TimedProbe = {
|
| 76 |
+
atMs: number;
|
| 77 |
+
error: string | null;
|
| 78 |
+
latencyMs: number;
|
| 79 |
+
ok: boolean;
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
type DiagnosticsTimelineSpan = {
|
| 83 |
+
durationMs?: number;
|
| 84 |
+
name?: string;
|
| 85 |
+
};
|
| 86 |
+
|
| 87 |
+
type ReadyProbe = TimedProbe & {
|
| 88 |
+
cpuCoreRatio: number | null;
|
| 89 |
+
degraded: boolean | null;
|
| 90 |
+
degradedSinceMs: number | null;
|
| 91 |
+
delayP99Ms: number | null;
|
| 92 |
+
delayMaxMs: number | null;
|
| 93 |
+
status: number;
|
| 94 |
+
utilization: number | null;
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
type ControlUiProbe = TimedProbe & {
|
| 98 |
+
status: number;
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
type GatewaySample = {
|
| 102 |
+
controlUi: ControlUiProbe;
|
| 103 |
+
readyz: ReadyProbe;
|
| 104 |
+
sessionsList: TimedProbe;
|
| 105 |
+
};
|
| 106 |
+
|
| 107 |
+
type FreshConnectionProbe = {
|
| 108 |
+
error: string | null;
|
| 109 |
+
latencyMs: number;
|
| 110 |
+
ok: boolean;
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
type GatewayChildExit = {
|
| 114 |
+
atMonotonicMicros: number;
|
| 115 |
+
exitCode: number | null;
|
| 116 |
+
signal: string | null;
|
| 117 |
+
};
|
| 118 |
+
|
| 119 |
+
type MainProfileArtifacts = {
|
| 120 |
+
scope: "main-isolate";
|
| 121 |
+
workersManifestPath: string;
|
| 122 |
+
};
|
| 123 |
+
|
| 124 |
+
type BenchmarkRun = {
|
| 125 |
+
agentCoverage?: {
|
| 126 |
+
configuredAgentIds: string[];
|
| 127 |
+
activeTurnAgentIds: string[];
|
| 128 |
+
beforeLoad: Array<{
|
| 129 |
+
agentId: string;
|
| 130 |
+
models: ModelsListResult;
|
| 131 |
+
sessions: SessionsListResult[];
|
| 132 |
+
}>;
|
| 133 |
+
completedTurns: Array<{ agentId: string; count: number }>;
|
| 134 |
+
};
|
| 135 |
+
browser?: {
|
| 136 |
+
newPageReadyMs: number;
|
| 137 |
+
initialSessionReadyMs: number | null;
|
| 138 |
+
historyMessagesPerTarget: number;
|
| 139 |
+
inventory: {
|
| 140 |
+
seededLoadSessions: number;
|
| 141 |
+
seededClickSessions: number;
|
| 142 |
+
unarchivedSessions: number;
|
| 143 |
+
retainedSessions: number;
|
| 144 |
+
};
|
| 145 |
+
clicks: BrowserSessionClick[];
|
| 146 |
+
};
|
| 147 |
+
heapProfile?: GatewayHeapProfile & MainProfileArtifacts;
|
| 148 |
+
loadCpuProfile?: GatewayCpuProfile & MainProfileArtifacts;
|
| 149 |
+
controlPlane: Array<TimedProbe & { method: string }>;
|
| 150 |
+
controlUi: ControlUiProbe[];
|
| 151 |
+
cpuUsage: GatewayCpuUsage;
|
| 152 |
+
durationMs: number;
|
| 153 |
+
freshConnection: FreshConnectionProbe;
|
| 154 |
+
gatewayExit?: Awaited<ReturnType<typeof stopChild>>;
|
| 155 |
+
gatewayProcess?: {
|
| 156 |
+
pid: number | undefined;
|
| 157 |
+
exitCode: number | null | undefined;
|
| 158 |
+
signalCode: string | null | undefined;
|
| 159 |
+
exitEvent: GatewayChildExit | undefined;
|
| 160 |
+
closeEvent: GatewayChildExit | undefined;
|
| 161 |
+
};
|
| 162 |
+
loadWindow?: { startMonotonicMicros: number; endMonotonicMicros: number };
|
| 163 |
+
history: Array<TimedProbe & { sessionKey?: string }>;
|
| 164 |
+
memory: { after: GatewayMemorySample; before: GatewayMemorySample; peakRssMb: number };
|
| 165 |
+
messageSubscriptions: TimedProbe[];
|
| 166 |
+
messageSubscriptionsDuringLoad: TimedProbe[];
|
| 167 |
+
mockRequests: ReturnType<typeof summarizeMockRequests>;
|
| 168 |
+
turnEvidence: ReturnType<ReturnType<typeof createTurnEvidence>["finish"]>;
|
| 169 |
+
probeWarmup: {
|
| 170 |
+
durationMs: number;
|
| 171 |
+
samples: GatewaySample[];
|
| 172 |
+
};
|
| 173 |
+
pluginMetadataScans: ReturnType<typeof summarizePluginMetadataScans>;
|
| 174 |
+
readyz: ReadyProbe[];
|
| 175 |
+
sessionSeedDurationMs: number;
|
| 176 |
+
sessionsList: TimedProbe[];
|
| 177 |
+
sessionUpdates: TimedProbe[];
|
| 178 |
+
setupDurationMs: number;
|
| 179 |
+
turnCount: number;
|
| 180 |
+
turnsDurationMs: number;
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
type CliOptions = {
|
| 184 |
+
agentCount: number;
|
| 185 |
+
browserHistoryMessages: number;
|
| 186 |
+
browserSessionClicks: number;
|
| 187 |
+
cadenceMs: number;
|
| 188 |
+
concurrency: number;
|
| 189 |
+
controlPlane: boolean;
|
| 190 |
+
cpuProfDir?: string;
|
| 191 |
+
loadCpuProfDir?: string;
|
| 192 |
+
heapProfDir?: string;
|
| 193 |
+
diagnosticsTimeline: boolean;
|
| 194 |
+
entry: string;
|
| 195 |
+
historyBurst: number;
|
| 196 |
+
historyClients: number;
|
| 197 |
+
historyMessages: number;
|
| 198 |
+
historyMessageChars: number;
|
| 199 |
+
json: boolean;
|
| 200 |
+
maxControlMs?: number;
|
| 201 |
+
maxHandshakeMs?: number;
|
| 202 |
+
output?: string;
|
| 203 |
+
pluginCount: number;
|
| 204 |
+
probeRounds?: number;
|
| 205 |
+
runs: number;
|
| 206 |
+
sessionCount: number;
|
| 207 |
+
sessionUpdateClients: number;
|
| 208 |
+
sessionUpdates: number;
|
| 209 |
+
streamChunkDelayMs: number;
|
| 210 |
+
subscribers: number;
|
| 211 |
+
timeoutMs: number;
|
| 212 |
+
toolEvents: boolean;
|
| 213 |
+
turnsPerSession: number;
|
| 214 |
+
visibleObserver: boolean;
|
| 215 |
+
warmup: number;
|
| 216 |
+
workspaceFanout: boolean;
|
| 217 |
+
};
|
| 218 |
+
|
| 219 |
+
const DEFAULT_CADENCE_MS = 100;
|
| 220 |
+
const DEFAULT_CONCURRENCY = 8;
|
| 221 |
+
const DEFAULT_ENTRY = "dist/entry.js";
|
| 222 |
+
const DEFAULT_RUNS = 1;
|
| 223 |
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
| 224 |
+
const DEFAULT_WARMUP = 0;
|
| 225 |
+
const MOCK_RESPONSE_CHUNK_DELAY_MS = 1_000;
|
| 226 |
+
const MAX_AGENT_COUNT = 128;
|
| 227 |
+
const MAX_CONCURRENCY = 64;
|
| 228 |
+
const MAX_TURNS_PER_SESSION = 100;
|
| 229 |
+
const MAX_PLUGIN_COUNT = 100;
|
| 230 |
+
const MAX_SESSION_COUNT = 10_000;
|
| 231 |
+
const MAX_SESSION_UPDATES = 100_000;
|
| 232 |
+
const MAX_SESSION_SEED_CONCURRENCY = 16;
|
| 233 |
+
const MAX_RUNS = 20;
|
| 234 |
+
const MAX_WARMUP = 10;
|
| 235 |
+
const MAX_SAMPLES_PER_RUN = 2_048;
|
| 236 |
+
const MAX_HTTP_BODY_BYTES = 1_048_576;
|
| 237 |
+
const HTTP_TIMEOUT_MS = 20_000;
|
| 238 |
+
const PROBE_WARMUP_TIMEOUT_MS = 60_000;
|
| 239 |
+
const PROBE_WARMUP_TARGET_MS = 1_000;
|
| 240 |
+
const PROBE_WARMUP_RETRY_DELAY_MS = 100;
|
| 241 |
+
const GATEWAY_STDERR_TAIL_LINES = 20;
|
| 242 |
+
const AGENT_WAIT_RPC_GRACE_MS = 5_000;
|
| 243 |
+
const UTILITY_MODEL_ID = "gateway-bench-utility";
|
| 244 |
+
const OBSERVER_ASSESSMENT = "Synthetic benchmark observation is valid.";
|
| 245 |
+
const MOCK_INGRESS_KEYS = ["responses", "chatCompletions", "embeddings", "other"] as const;
|
| 246 |
+
const MOCK_SELECTION_KEYS = ["model", "global", "automaticTool", "automaticText"] as const;
|
| 247 |
+
type MockRequestSnapshot = {
|
| 248 |
+
id: string;
|
| 249 |
+
ingress: Record<(typeof MOCK_INGRESS_KEYS)[number], number>;
|
| 250 |
+
selections: Record<(typeof MOCK_SELECTION_KEYS)[number], number>;
|
| 251 |
+
beforeMs: number;
|
| 252 |
+
afterMs: number;
|
| 253 |
+
};
|
| 254 |
+
const BOOLEAN_FLAGS = new Set([
|
| 255 |
+
"--help",
|
| 256 |
+
"-h",
|
| 257 |
+
"--json",
|
| 258 |
+
"--control-plane",
|
| 259 |
+
"--no-diagnostics-timeline",
|
| 260 |
+
"--tool-events",
|
| 261 |
+
"--visible-observer",
|
| 262 |
+
"--workspace-fanout",
|
| 263 |
+
]);
|
| 264 |
+
const VALUE_FLAGS = new Set([
|
| 265 |
+
"--agent-count",
|
| 266 |
+
"--browser-history-messages",
|
| 267 |
+
"--browser-session-clicks",
|
| 268 |
+
"--cadence-ms",
|
| 269 |
+
"--concurrency",
|
| 270 |
+
"--cpu-prof-dir",
|
| 271 |
+
"--load-cpu-prof-dir",
|
| 272 |
+
"--heap-prof-dir",
|
| 273 |
+
"--entry",
|
| 274 |
+
"--history-burst",
|
| 275 |
+
"--history-clients",
|
| 276 |
+
"--history-messages",
|
| 277 |
+
"--history-message-chars",
|
| 278 |
+
"--max-control-ms",
|
| 279 |
+
"--max-handshake-ms",
|
| 280 |
+
"--output",
|
| 281 |
+
"--plugin-count",
|
| 282 |
+
"--probe-rounds",
|
| 283 |
+
"--runs",
|
| 284 |
+
"--session-count",
|
| 285 |
+
"--session-update-clients",
|
| 286 |
+
"--session-updates",
|
| 287 |
+
"--stream-chunk-delay-ms",
|
| 288 |
+
"--subscribers",
|
| 289 |
+
"--timeout-ms",
|
| 290 |
+
"--turns-per-session",
|
| 291 |
+
"--warmup",
|
| 292 |
+
]);
|
| 293 |
+
|
| 294 |
+
function parseBoundedPositiveInt(
|
| 295 |
+
raw: string | undefined,
|
| 296 |
+
fallback: number,
|
| 297 |
+
label: string,
|
| 298 |
+
max: number,
|
| 299 |
+
): number {
|
| 300 |
+
const value = parsePositiveInt(raw, fallback, label);
|
| 301 |
+
if (value > max) {
|
| 302 |
+
throw new CliArgumentError(`${label} must be at most ${max}`);
|
| 303 |
+
}
|
| 304 |
+
return value;
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
function parseBoundedNonNegativeInt(
|
| 308 |
+
raw: string | undefined,
|
| 309 |
+
fallback: number,
|
| 310 |
+
label: string,
|
| 311 |
+
max: number,
|
| 312 |
+
): number {
|
| 313 |
+
const value = parseNonNegativeInt(raw, fallback, label);
|
| 314 |
+
if (value > max) {
|
| 315 |
+
throw new CliArgumentError(`${label} must be at most ${max}`);
|
| 316 |
+
}
|
| 317 |
+
return value;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
|
| 321 |
+
validateCliArgs(argv, { booleanFlags: BOOLEAN_FLAGS, valueFlags: VALUE_FLAGS });
|
| 322 |
+
const options = {
|
| 323 |
+
agentCount: parseBoundedPositiveInt(
|
| 324 |
+
parseFlagValue(argv, "--agent-count"),
|
| 325 |
+
1,
|
| 326 |
+
"--agent-count",
|
| 327 |
+
MAX_AGENT_COUNT,
|
| 328 |
+
),
|
| 329 |
+
browserHistoryMessages: parseBoundedPositiveInt(
|
| 330 |
+
parseFlagValue(argv, "--browser-history-messages"),
|
| 331 |
+
80,
|
| 332 |
+
"--browser-history-messages",
|
| 333 |
+
500,
|
| 334 |
+
),
|
| 335 |
+
browserSessionClicks: parseBoundedNonNegativeInt(
|
| 336 |
+
parseFlagValue(argv, "--browser-session-clicks"),
|
| 337 |
+
0,
|
| 338 |
+
"--browser-session-clicks",
|
| 339 |
+
20,
|
| 340 |
+
),
|
| 341 |
+
cadenceMs: parseBoundedPositiveInt(
|
| 342 |
+
parseFlagValue(argv, "--cadence-ms"),
|
| 343 |
+
DEFAULT_CADENCE_MS,
|
| 344 |
+
"--cadence-ms",
|
| 345 |
+
5_000,
|
| 346 |
+
),
|
| 347 |
+
concurrency: parseBoundedPositiveInt(
|
| 348 |
+
parseFlagValue(argv, "--concurrency"),
|
| 349 |
+
DEFAULT_CONCURRENCY,
|
| 350 |
+
"--concurrency",
|
| 351 |
+
MAX_CONCURRENCY,
|
| 352 |
+
),
|
| 353 |
+
controlPlane: hasFlag(argv, "--control-plane"),
|
| 354 |
+
cpuProfDir: resolveOutputPath(parseFlagValue(argv, "--cpu-prof-dir")),
|
| 355 |
+
loadCpuProfDir: resolveOutputPath(parseFlagValue(argv, "--load-cpu-prof-dir")),
|
| 356 |
+
heapProfDir: resolveOutputPath(parseFlagValue(argv, "--heap-prof-dir")),
|
| 357 |
+
diagnosticsTimeline: !hasFlag(argv, "--no-diagnostics-timeline"),
|
| 358 |
+
entry: resolveEntry(parseFlagValue(argv, "--entry"), DEFAULT_ENTRY),
|
| 359 |
+
historyBurst: parseBoundedPositiveInt(
|
| 360 |
+
parseFlagValue(argv, "--history-burst"),
|
| 361 |
+
5,
|
| 362 |
+
"--history-burst",
|
| 363 |
+
32,
|
| 364 |
+
),
|
| 365 |
+
historyClients: parseBoundedNonNegativeInt(
|
| 366 |
+
parseFlagValue(argv, "--history-clients"),
|
| 367 |
+
0,
|
| 368 |
+
"--history-clients",
|
| 369 |
+
MAX_CONCURRENCY,
|
| 370 |
+
),
|
| 371 |
+
historyMessages: parseBoundedNonNegativeInt(
|
| 372 |
+
parseFlagValue(argv, "--history-messages"),
|
| 373 |
+
0,
|
| 374 |
+
"--history-messages",
|
| 375 |
+
500,
|
| 376 |
+
),
|
| 377 |
+
historyMessageChars: parseBoundedPositiveInt(
|
| 378 |
+
parseFlagValue(argv, "--history-message-chars"),
|
| 379 |
+
1_024,
|
| 380 |
+
"--history-message-chars",
|
| 381 |
+
65_536,
|
| 382 |
+
),
|
| 383 |
+
json: hasFlag(argv, "--json"),
|
| 384 |
+
maxControlMs: parseFlagValue(argv, "--max-control-ms")
|
| 385 |
+
? parseBoundedPositiveInt(
|
| 386 |
+
parseFlagValue(argv, "--max-control-ms"),
|
| 387 |
+
2_000,
|
| 388 |
+
"--max-control-ms",
|
| 389 |
+
30_000,
|
| 390 |
+
)
|
| 391 |
+
: undefined,
|
| 392 |
+
maxHandshakeMs: parseFlagValue(argv, "--max-handshake-ms")
|
| 393 |
+
? parseBoundedPositiveInt(
|
| 394 |
+
parseFlagValue(argv, "--max-handshake-ms"),
|
| 395 |
+
2_000,
|
| 396 |
+
"--max-handshake-ms",
|
| 397 |
+
30_000,
|
| 398 |
+
)
|
| 399 |
+
: undefined,
|
| 400 |
+
output: resolveOutputPath(parseFlagValue(argv, "--output")),
|
| 401 |
+
pluginCount: parseBoundedNonNegativeInt(
|
| 402 |
+
parseFlagValue(argv, "--plugin-count"),
|
| 403 |
+
0,
|
| 404 |
+
"--plugin-count",
|
| 405 |
+
MAX_PLUGIN_COUNT,
|
| 406 |
+
),
|
| 407 |
+
probeRounds:
|
| 408 |
+
parseFlagValue(argv, "--probe-rounds") === undefined
|
| 409 |
+
? undefined
|
| 410 |
+
: parseBoundedPositiveInt(
|
| 411 |
+
parseFlagValue(argv, "--probe-rounds"),
|
| 412 |
+
1,
|
| 413 |
+
"--probe-rounds",
|
| 414 |
+
MAX_SAMPLES_PER_RUN,
|
| 415 |
+
),
|
| 416 |
+
runs: parseBoundedPositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs", MAX_RUNS),
|
| 417 |
+
sessionCount: parseBoundedNonNegativeInt(
|
| 418 |
+
parseFlagValue(argv, "--session-count"),
|
| 419 |
+
0,
|
| 420 |
+
"--session-count",
|
| 421 |
+
MAX_SESSION_COUNT,
|
| 422 |
+
),
|
| 423 |
+
sessionUpdateClients: parseBoundedPositiveInt(
|
| 424 |
+
parseFlagValue(argv, "--session-update-clients"),
|
| 425 |
+
4,
|
| 426 |
+
"--session-update-clients",
|
| 427 |
+
MAX_CONCURRENCY,
|
| 428 |
+
),
|
| 429 |
+
sessionUpdates: parseBoundedNonNegativeInt(
|
| 430 |
+
parseFlagValue(argv, "--session-updates"),
|
| 431 |
+
0,
|
| 432 |
+
"--session-updates",
|
| 433 |
+
MAX_SESSION_UPDATES,
|
| 434 |
+
),
|
| 435 |
+
streamChunkDelayMs: parseBoundedPositiveInt(
|
| 436 |
+
parseFlagValue(argv, "--stream-chunk-delay-ms"),
|
| 437 |
+
MOCK_RESPONSE_CHUNK_DELAY_MS,
|
| 438 |
+
"--stream-chunk-delay-ms",
|
| 439 |
+
30_000,
|
| 440 |
+
),
|
| 441 |
+
subscribers: parseBoundedNonNegativeInt(
|
| 442 |
+
parseFlagValue(argv, "--subscribers"),
|
| 443 |
+
0,
|
| 444 |
+
"--subscribers",
|
| 445 |
+
MAX_CONCURRENCY,
|
| 446 |
+
),
|
| 447 |
+
timeoutMs: parseBoundedPositiveInt(
|
| 448 |
+
parseFlagValue(argv, "--timeout-ms"),
|
| 449 |
+
DEFAULT_TIMEOUT_MS,
|
| 450 |
+
"--timeout-ms",
|
| 451 |
+
10 * 60_000,
|
| 452 |
+
),
|
| 453 |
+
toolEvents: hasFlag(argv, "--tool-events"),
|
| 454 |
+
turnsPerSession: parseBoundedPositiveInt(
|
| 455 |
+
parseFlagValue(argv, "--turns-per-session"),
|
| 456 |
+
1,
|
| 457 |
+
"--turns-per-session",
|
| 458 |
+
MAX_TURNS_PER_SESSION,
|
| 459 |
+
),
|
| 460 |
+
visibleObserver: hasFlag(argv, "--visible-observer"),
|
| 461 |
+
warmup: parseBoundedNonNegativeInt(
|
| 462 |
+
parseFlagValue(argv, "--warmup"),
|
| 463 |
+
DEFAULT_WARMUP,
|
| 464 |
+
"--warmup",
|
| 465 |
+
MAX_WARMUP,
|
| 466 |
+
),
|
| 467 |
+
workspaceFanout: hasFlag(argv, "--workspace-fanout"),
|
| 468 |
+
};
|
| 469 |
+
if (options.loadCpuProfDir && options.heapProfDir) {
|
| 470 |
+
throw new CliArgumentError(
|
| 471 |
+
"--load-cpu-prof-dir and --heap-prof-dir require separate benchmark runs",
|
| 472 |
+
);
|
| 473 |
+
}
|
| 474 |
+
if (options.agentCount > Math.max(options.sessionCount, options.concurrency)) {
|
| 475 |
+
throw new CliArgumentError("--agent-count must not exceed the total session count");
|
| 476 |
+
}
|
| 477 |
+
const historyMessageCount =
|
| 478 |
+
Math.max(options.sessionCount, options.concurrency) * options.historyMessages +
|
| 479 |
+
(options.browserSessionClicks > 0
|
| 480 |
+
? (options.browserSessionClicks + 1) * options.browserHistoryMessages
|
| 481 |
+
: 0);
|
| 482 |
+
if (
|
| 483 |
+
options.probeRounds !== undefined &&
|
| 484 |
+
options.probeRounds * options.historyClients * options.historyBurst > MAX_SAMPLES_PER_RUN
|
| 485 |
+
) {
|
| 486 |
+
throw new CliArgumentError("fixed history workload must not exceed 2048 requests per run");
|
| 487 |
+
}
|
| 488 |
+
if (
|
| 489 |
+
historyMessageCount > 100_000 ||
|
| 490 |
+
historyMessageCount * options.historyMessageChars > 256 * 1024 * 1024
|
| 491 |
+
) {
|
| 492 |
+
throw new CliArgumentError("synthetic history must not exceed 100000 messages or 256 MiB");
|
| 493 |
+
}
|
| 494 |
+
return options;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
function printUsage(): void {
|
| 498 |
+
console.log(`OpenClaw Gateway concurrency benchmark
|
| 499 |
+
|
| 500 |
+
Usage:
|
| 501 |
+
pnpm test:gateway:concurrency -- [options]
|
| 502 |
+
node scripts/bench-gateway-concurrency.ts [options]
|
| 503 |
+
|
| 504 |
+
Options:
|
| 505 |
+
--agent-count <n> Configured agents sharing the fixed session inventory (default: 1, max: ${MAX_AGENT_COUNT})
|
| 506 |
+
--browser-history-messages <n> Messages per browser click target, independent of inventory history (default: 80, max: 500)
|
| 507 |
+
--browser-session-clicks <n> Click n existing sessions and revisit one during load (default: 0, max: 20; requires built UI and Chromium)
|
| 508 |
+
--concurrency <n> Concurrent synthetic sessions (default: ${DEFAULT_CONCURRENCY})
|
| 509 |
+
--turns-per-session <n> Serial turns per session (default: 1, max: ${MAX_TURNS_PER_SESSION})
|
| 510 |
+
--control-plane Also probe tasks.list, cron.list, and cron.status during load
|
| 511 |
+
--history-messages <n> Inject up to 500 synthetic messages per seeded session
|
| 512 |
+
--history-message-chars <n> Synthetic message size (default: 1024, max: 65536)
|
| 513 |
+
--cpu-prof-dir <p> Write Gateway V8 CPU profiles to this directory
|
| 514 |
+
--load-cpu-prof-dir <p> Capture load-phase main/Worker CPU over private IPC, including on Windows
|
| 515 |
+
--heap-prof-dir <p> Sample load-phase main/Worker allocations, including GC-collected objects
|
| 516 |
+
--runs <n> Measured gateway runs (default: ${DEFAULT_RUNS})
|
| 517 |
+
--warmup <n> Warmup gateway runs (default: ${DEFAULT_WARMUP})
|
| 518 |
+
--cadence-ms <ms> Probe cadence (default: ${DEFAULT_CADENCE_MS})
|
| 519 |
+
--probe-rounds <n> Run exactly n sampler rounds and n bursts per history client
|
| 520 |
+
--timeout-ms <ms> Per-run cap, excluding probe warmup (default: ${DEFAULT_TIMEOUT_MS})
|
| 521 |
+
--entry <path> Gateway CLI entry file (default: ${DEFAULT_ENTRY})
|
| 522 |
+
--session-count <n> Seed up to ${MAX_SESSION_COUNT} distinct sessions before load
|
| 523 |
+
--session-updates <n> Bounded public sessions.patch mutations during load
|
| 524 |
+
--session-update-clients <n> Concurrent session mutation clients (default: 4)
|
| 525 |
+
--history-clients <n> Concurrent dedicated history-prefetch WebSocket clients
|
| 526 |
+
--history-burst <n> Parallel history requests per prefetch client (default: 5)
|
| 527 |
+
--subscribers <n> Dedicated session-message subscription clients
|
| 528 |
+
--stream-chunk-delay-ms <n> Mock-provider delay between stream chunks (default: ${MOCK_RESPONSE_CHUNK_DELAY_MS})
|
| 529 |
+
--visible-observer Mark subscribed clients visible to exercise session observation
|
| 530 |
+
--no-diagnostics-timeline Disable diagnostics timeline file writes
|
| 531 |
+
--plugin-count <n> Configure synthetic plugins through plugins.load.paths (default: 0)
|
| 532 |
+
--tool-events Make every synthetic turn execute a tool before replying
|
| 533 |
+
--workspace-fanout Bind each session to a distinct workspace
|
| 534 |
+
--max-control-ms Fail when any load-phase health/control probe exceeds this bound
|
| 535 |
+
--max-handshake-ms Fail when a fresh authenticated connection exceeds this bound
|
| 536 |
+
--output <path> Write machine-readable JSON to a file
|
| 537 |
+
--json Emit machine-readable JSON
|
| 538 |
+
--help, -h Show this text
|
| 539 |
+
`);
|
| 540 |
+
}
|
| 541 |
+
|
| 542 |
+
function percentile(sorted: readonly number[], percentileValue: number): number {
|
| 543 |
+
const index = Math.max(
|
| 544 |
+
0,
|
| 545 |
+
Math.min(sorted.length - 1, Math.ceil((percentileValue / 100) * sorted.length) - 1),
|
| 546 |
+
);
|
| 547 |
+
return sorted[index] ?? 0;
|
| 548 |
+
}
|
| 549 |
+
|
| 550 |
+
function summarizeNumbers(values: readonly number[]): MetricSummary | null {
|
| 551 |
+
const sorted = values.filter(Number.isFinite).toSorted((a, b) => a - b);
|
| 552 |
+
if (sorted.length === 0) {
|
| 553 |
+
return null;
|
| 554 |
+
}
|
| 555 |
+
return {
|
| 556 |
+
count: sorted.length,
|
| 557 |
+
max: sorted.at(-1) ?? 0,
|
| 558 |
+
p50: percentile(sorted, 50),
|
| 559 |
+
p95: percentile(sorted, 95),
|
| 560 |
+
p99: percentile(sorted, 99),
|
| 561 |
+
};
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
function summarizePluginMetadataScans(events: readonly DiagnosticsTimelineSpan[]) {
|
| 565 |
+
const durations = events.flatMap((event) =>
|
| 566 |
+
event.name === "plugins.metadata.scan" &&
|
| 567 |
+
typeof event.durationMs === "number" &&
|
| 568 |
+
Number.isFinite(event.durationMs)
|
| 569 |
+
? [event.durationMs]
|
| 570 |
+
: [],
|
| 571 |
+
);
|
| 572 |
+
return {
|
| 573 |
+
count: durations.length,
|
| 574 |
+
durationMs: summarizeNumbers(durations),
|
| 575 |
+
totalDurationMs: durations.reduce((sum, durationMs) => sum + durationMs, 0),
|
| 576 |
+
};
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
function readDiagnosticsTimelineSpans(
|
| 580 |
+
timelinePath: string,
|
| 581 |
+
window?: { from: number; through: number },
|
| 582 |
+
): DiagnosticsTimelineSpan[] {
|
| 583 |
+
const contents = readFileSync(timelinePath, "utf8");
|
| 584 |
+
if (!contents.trim() || !contents.endsWith("\n")) {
|
| 585 |
+
throw new Error("diagnostics timeline is empty or incomplete");
|
| 586 |
+
}
|
| 587 |
+
return contents
|
| 588 |
+
.split(/\r?\n/u)
|
| 589 |
+
.filter(Boolean)
|
| 590 |
+
.flatMap((line) => {
|
| 591 |
+
const event: unknown = JSON.parse(line);
|
| 592 |
+
if (
|
| 593 |
+
!isRecord(event) ||
|
| 594 |
+
event.schemaVersion !== "openclaw.diagnostics.v1" ||
|
| 595 |
+
typeof event.timestamp !== "string" ||
|
| 596 |
+
!Number.isFinite(Date.parse(event.timestamp))
|
| 597 |
+
) {
|
| 598 |
+
throw new Error("invalid diagnostics timeline record");
|
| 599 |
+
}
|
| 600 |
+
const timestamp = Date.parse(event.timestamp);
|
| 601 |
+
if (window && (timestamp < window.from || timestamp > window.through)) {
|
| 602 |
+
return [];
|
| 603 |
+
}
|
| 604 |
+
if (event.type !== "span.end") {
|
| 605 |
+
return [];
|
| 606 |
+
}
|
| 607 |
+
if (
|
| 608 |
+
typeof event.name !== "string" ||
|
| 609 |
+
typeof event.durationMs !== "number" ||
|
| 610 |
+
!Number.isFinite(event.durationMs)
|
| 611 |
+
) {
|
| 612 |
+
throw new Error("invalid diagnostics timeline span");
|
| 613 |
+
}
|
| 614 |
+
return [{ name: event.name, durationMs: event.durationMs }];
|
| 615 |
+
});
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
+
function remainingMs(deadlineAt: number): number {
|
| 619 |
+
return Math.max(0, deadlineAt - performance.now());
|
| 620 |
+
}
|
| 621 |
+
|
| 622 |
+
function requireRemainingMs(deadlineAt: number, label: string): number {
|
| 623 |
+
const remaining = remainingMs(deadlineAt);
|
| 624 |
+
if (remaining <= 0) {
|
| 625 |
+
throw new Error(`benchmark timed out while ${label}`);
|
| 626 |
+
}
|
| 627 |
+
return remaining;
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
async function requestHttp(params: {
|
| 631 |
+
accept: string;
|
| 632 |
+
deadlineAt: number;
|
| 633 |
+
path: string;
|
| 634 |
+
port: number;
|
| 635 |
+
}): Promise<{ body: string; latencyMs: number; status: number }> {
|
| 636 |
+
const startedAt = performance.now();
|
| 637 |
+
const requestDeadlineAt = Math.min(params.deadlineAt, startedAt + HTTP_TIMEOUT_MS);
|
| 638 |
+
requireRemainingMs(requestDeadlineAt, `requesting ${params.path}`);
|
| 639 |
+
return await new Promise((resolve, reject) => {
|
| 640 |
+
let settled = false;
|
| 641 |
+
const settle = (run: () => void) => {
|
| 642 |
+
if (settled) {
|
| 643 |
+
return;
|
| 644 |
+
}
|
| 645 |
+
settled = true;
|
| 646 |
+
clearTimeout(timer);
|
| 647 |
+
run();
|
| 648 |
+
};
|
| 649 |
+
const fail = (error: Error) =>
|
| 650 |
+
settle(() => {
|
| 651 |
+
req.destroy();
|
| 652 |
+
reject(error);
|
| 653 |
+
});
|
| 654 |
+
const req = request(
|
| 655 |
+
{
|
| 656 |
+
headers: { accept: params.accept },
|
| 657 |
+
host: "127.0.0.1",
|
| 658 |
+
method: "GET",
|
| 659 |
+
path: params.path,
|
| 660 |
+
port: params.port,
|
| 661 |
+
},
|
| 662 |
+
(res) => {
|
| 663 |
+
const chunks: Buffer[] = [];
|
| 664 |
+
let bytes = 0;
|
| 665 |
+
res.on("data", (chunk: Buffer) => {
|
| 666 |
+
bytes += chunk.length;
|
| 667 |
+
if (bytes > MAX_HTTP_BODY_BYTES) {
|
| 668 |
+
fail(new Error(`${params.path} response exceeded ${MAX_HTTP_BODY_BYTES} bytes`));
|
| 669 |
+
return;
|
| 670 |
+
}
|
| 671 |
+
chunks.push(chunk);
|
| 672 |
+
});
|
| 673 |
+
res.once("aborted", () => fail(new Error(`${params.path} response aborted`)));
|
| 674 |
+
res.once("error", fail);
|
| 675 |
+
res.once("end", () =>
|
| 676 |
+
settle(() =>
|
| 677 |
+
resolve({
|
| 678 |
+
body: Buffer.concat(chunks).toString("utf8"),
|
| 679 |
+
latencyMs: performance.now() - startedAt,
|
| 680 |
+
status: res.statusCode ?? 0,
|
| 681 |
+
}),
|
| 682 |
+
),
|
| 683 |
+
);
|
| 684 |
+
},
|
| 685 |
+
);
|
| 686 |
+
req.once("error", fail);
|
| 687 |
+
// Request/socket timeouts measure inactivity; this timer owns the wall-clock deadline.
|
| 688 |
+
const timer = setTimeout(
|
| 689 |
+
() => fail(new Error(`${params.path} request timed out`)),
|
| 690 |
+
Math.max(1, Math.ceil(remainingMs(requestDeadlineAt))),
|
| 691 |
+
);
|
| 692 |
+
timer.unref?.();
|
| 693 |
+
req.end();
|
| 694 |
+
});
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
function describeProbeError(error: unknown): string {
|
| 698 |
+
return sliceUtf16Safe(error instanceof Error ? error.message : String(error), 0, 500);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
function formatProbeResult(name: string, probe: TimedProbe & { status?: number }): string {
|
| 702 |
+
const status = probe.status === undefined ? "n/a" : probe.status;
|
| 703 |
+
return `${name}: ok=${probe.ok} status=${status} latencyMs=${probe.latencyMs.toFixed(1)} error=${probe.error ? JSON.stringify(probe.error) : "none"}`;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
+
function formatProbeFailure(sample: GatewaySample): string {
|
| 707 |
+
return [
|
| 708 |
+
"gateway probes did not become fast and healthy before concurrent load",
|
| 709 |
+
formatProbeResult("readyz", sample.readyz),
|
| 710 |
+
formatProbeResult("sessionsList", sample.sessionsList),
|
| 711 |
+
formatProbeResult("controlUi", sample.controlUi),
|
| 712 |
+
].join("\n ");
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
function tailLines(output: string, lineCount: number): string {
|
| 716 |
+
return output.trimEnd().split(/\r?\n/u).slice(-lineCount).join("\n");
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
function captureChildOutput(child: ChildProcess): {
|
| 720 |
+
readOutput: () => string;
|
| 721 |
+
readStderrTail: () => string;
|
| 722 |
+
} {
|
| 723 |
+
if (!child.stdout || !child.stderr) {
|
| 724 |
+
throw new Error("Gateway benchmark children require piped stdout and stderr");
|
| 725 |
+
}
|
| 726 |
+
let output = "";
|
| 727 |
+
let stderr = "";
|
| 728 |
+
const appendOutput = (chunk: Buffer) => {
|
| 729 |
+
output = sliceUtf16Safe(`${output}${chunk.toString("utf8")}`, -64 * 1_024);
|
| 730 |
+
};
|
| 731 |
+
child.stdout.on("data", appendOutput);
|
| 732 |
+
child.stderr.on("data", (chunk: Buffer) => {
|
| 733 |
+
appendOutput(chunk);
|
| 734 |
+
stderr = sliceUtf16Safe(`${stderr}${chunk.toString("utf8")}`, -64 * 1_024);
|
| 735 |
+
});
|
| 736 |
+
return {
|
| 737 |
+
readOutput: () => output,
|
| 738 |
+
readStderrTail: () => tailLines(stderr, GATEWAY_STDERR_TAIL_LINES),
|
| 739 |
+
};
|
| 740 |
+
}
|
| 741 |
+
|
| 742 |
+
function formatRunFailure(
|
| 743 |
+
error: unknown,
|
| 744 |
+
gatewayOutput: { readOutput: () => string; readStderrTail: () => string },
|
| 745 |
+
mockOutput: { readOutput: () => string },
|
| 746 |
+
): string {
|
| 747 |
+
return [
|
| 748 |
+
error instanceof Error ? error.message : String(error),
|
| 749 |
+
gatewayOutput.readStderrTail()
|
| 750 |
+
? `gateway stderr tail:\n${gatewayOutput.readStderrTail()}`
|
| 751 |
+
: "gateway stderr tail: (empty)",
|
| 752 |
+
gatewayOutput.readOutput() ? `gateway output tail:\n${gatewayOutput.readOutput()}` : "",
|
| 753 |
+
mockOutput.readOutput() ? `mock provider output tail:\n${mockOutput.readOutput()}` : "",
|
| 754 |
+
]
|
| 755 |
+
.filter(Boolean)
|
| 756 |
+
.join("\n");
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
async function waitForMockServer(port: number, deadlineAt: number): Promise<void> {
|
| 760 |
+
let lastError: unknown;
|
| 761 |
+
while (remainingMs(deadlineAt) > 0) {
|
| 762 |
+
try {
|
| 763 |
+
const result = await requestHttp({
|
| 764 |
+
accept: "application/json",
|
| 765 |
+
deadlineAt,
|
| 766 |
+
path: "/health",
|
| 767 |
+
port,
|
| 768 |
+
});
|
| 769 |
+
if (result.status === 200) {
|
| 770 |
+
return;
|
| 771 |
+
}
|
| 772 |
+
} catch (error) {
|
| 773 |
+
lastError = error;
|
| 774 |
+
}
|
| 775 |
+
await delay(Math.min(25, remainingMs(deadlineAt)));
|
| 776 |
+
}
|
| 777 |
+
const detail =
|
| 778 |
+
lastError instanceof Error
|
| 779 |
+
? lastError.message
|
| 780 |
+
: typeof lastError === "string"
|
| 781 |
+
? lastError
|
| 782 |
+
: "timeout";
|
| 783 |
+
throw new Error(`mock provider did not become healthy: ${detail}`);
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
async function waitForGatewayDispatchReady(
|
| 787 |
+
readOutput: () => string,
|
| 788 |
+
deadlineAt: number,
|
| 789 |
+
): Promise<void> {
|
| 790 |
+
while (remainingMs(deadlineAt) > 0) {
|
| 791 |
+
if (readOutput().includes("startup trace: sidecars.ready ")) {
|
| 792 |
+
return;
|
| 793 |
+
}
|
| 794 |
+
await delay(Math.min(25, remainingMs(deadlineAt)));
|
| 795 |
+
}
|
| 796 |
+
throw new Error("gateway did not finish dispatch-ready sidecars");
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
function parseMockRequests(value: unknown, beforeMs: number, afterMs: number): MockRequestSnapshot {
|
| 800 |
+
if (!isRecord(value) || typeof value.id !== "string" || !value.id || value.id.length > 64) {
|
| 801 |
+
throw new Error("mock request counter identity is missing");
|
| 802 |
+
}
|
| 803 |
+
const counts = <K extends string>(record: unknown, keys: readonly K[]): Record<K, number> => {
|
| 804 |
+
if (
|
| 805 |
+
!isRecord(record) ||
|
| 806 |
+
Object.keys(record).length !== keys.length ||
|
| 807 |
+
keys.some((key) => !Number.isSafeInteger(record[key]) || (record[key] as number) < 0)
|
| 808 |
+
) {
|
| 809 |
+
throw new Error("mock request counters are invalid");
|
| 810 |
+
}
|
| 811 |
+
return Object.fromEntries(keys.map((key) => [key, record[key]])) as Record<K, number>;
|
| 812 |
+
};
|
| 813 |
+
return {
|
| 814 |
+
id: value.id,
|
| 815 |
+
ingress: counts(value.ingress, MOCK_INGRESS_KEYS),
|
| 816 |
+
selections: counts(value.selections, MOCK_SELECTION_KEYS),
|
| 817 |
+
beforeMs,
|
| 818 |
+
afterMs,
|
| 819 |
+
};
|
| 820 |
+
}
|
| 821 |
+
|
| 822 |
+
async function readMockRequests(port: number, deadlineAt: number): Promise<MockRequestSnapshot> {
|
| 823 |
+
const beforeMs = performance.now();
|
| 824 |
+
const response = await requestHttp({
|
| 825 |
+
accept: "application/json",
|
| 826 |
+
port,
|
| 827 |
+
path: "/health",
|
| 828 |
+
deadlineAt,
|
| 829 |
+
});
|
| 830 |
+
const afterMs = performance.now();
|
| 831 |
+
if (response.status !== 200) {
|
| 832 |
+
throw new Error(`mock request checkpoint failed: HTTP ${response.status}`);
|
| 833 |
+
}
|
| 834 |
+
return parseMockRequests(JSON.parse(response.body).requests, beforeMs, afterMs);
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
function summarizeMockRequests(checkpoints: readonly MockRequestSnapshot[]) {
|
| 838 |
+
const phases = ["startupAndWarmup", "setup", "loadBracket", "postLoad"] as const;
|
| 839 |
+
if (checkpoints.length !== phases.length + 1) {
|
| 840 |
+
throw new Error("mock request checkpoints are incomplete");
|
| 841 |
+
}
|
| 842 |
+
const first = checkpoints[0]!;
|
| 843 |
+
if (
|
| 844 |
+
[...Object.values(first.ingress), ...Object.values(first.selections)].some(
|
| 845 |
+
(count) => count !== 0,
|
| 846 |
+
)
|
| 847 |
+
) {
|
| 848 |
+
throw new Error("mock request counters were not zero before Gateway startup");
|
| 849 |
+
}
|
| 850 |
+
for (let index = 0; index < checkpoints.length; index += 1) {
|
| 851 |
+
const current = checkpoints[index]!;
|
| 852 |
+
const previous = checkpoints[Math.max(0, index - 1)]!;
|
| 853 |
+
if (
|
| 854 |
+
current.id !== first.id ||
|
| 855 |
+
!Number.isFinite(current.beforeMs) ||
|
| 856 |
+
!Number.isFinite(current.afterMs) ||
|
| 857 |
+
current.afterMs < current.beforeMs ||
|
| 858 |
+
(index > 0 && current.beforeMs < previous.afterMs) ||
|
| 859 |
+
MOCK_INGRESS_KEYS.some((key) => current.ingress[key] < previous.ingress[key]) ||
|
| 860 |
+
MOCK_SELECTION_KEYS.some((key) => current.selections[key] < previous.selections[key])
|
| 861 |
+
) {
|
| 862 |
+
throw new Error("mock request checkpoints changed identity or regressed");
|
| 863 |
+
}
|
| 864 |
+
}
|
| 865 |
+
const difference = (after: MockRequestSnapshot, before: MockRequestSnapshot) =>
|
| 866 |
+
Object.fromEntries(
|
| 867 |
+
MOCK_INGRESS_KEYS.map((key) => [key, after.ingress[key] - before.ingress[key]]),
|
| 868 |
+
) as MockRequestSnapshot["ingress"];
|
| 869 |
+
return {
|
| 870 |
+
// Acknowledged HTTP brackets partition ingress, not causal work or exact CPU windows.
|
| 871 |
+
checkpoints,
|
| 872 |
+
ingress: {
|
| 873 |
+
...Object.fromEntries(
|
| 874 |
+
phases.map((phase, index) => [
|
| 875 |
+
phase,
|
| 876 |
+
difference(checkpoints[index + 1]!, checkpoints[index]!),
|
| 877 |
+
]),
|
| 878 |
+
),
|
| 879 |
+
total: difference(checkpoints.at(-1)!, first),
|
| 880 |
+
},
|
| 881 |
+
selections: { ...checkpoints.at(-1)!.selections },
|
| 882 |
+
};
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
function createTurnEvidence(toolEvents: boolean) {
|
| 886 |
+
const turns = new Map<
|
| 887 |
+
string,
|
| 888 |
+
{
|
| 889 |
+
sessionKey: string;
|
| 890 |
+
toolCallId?: string;
|
| 891 |
+
toolCompleted: boolean;
|
| 892 |
+
final: boolean;
|
| 893 |
+
observer: boolean;
|
| 894 |
+
}
|
| 895 |
+
>();
|
| 896 |
+
let invalid = false;
|
| 897 |
+
return {
|
| 898 |
+
register(runId: string, sessionKey: string) {
|
| 899 |
+
if (turns.has(runId)) {
|
| 900 |
+
throw new Error("duplicate benchmark run identity");
|
| 901 |
+
}
|
| 902 |
+
turns.set(runId, { sessionKey, toolCompleted: false, final: false, observer: false });
|
| 903 |
+
},
|
| 904 |
+
onEvent(this: void, event: { event: string; payload?: unknown }) {
|
| 905 |
+
const payload = event.payload;
|
| 906 |
+
if (!isRecord(payload) || typeof payload.runId !== "string") {
|
| 907 |
+
return;
|
| 908 |
+
}
|
| 909 |
+
const turn = turns.get(payload.runId);
|
| 910 |
+
if (!turn) {
|
| 911 |
+
return;
|
| 912 |
+
}
|
| 913 |
+
if (event.event === "session.observer") {
|
| 914 |
+
if (payload.sessionKey === turn.sessionKey && payload.assessment === OBSERVER_ASSESSMENT) {
|
| 915 |
+
turn.observer = true;
|
| 916 |
+
}
|
| 917 |
+
return;
|
| 918 |
+
}
|
| 919 |
+
if (!toolEvents || event.event !== "session.tool") {
|
| 920 |
+
return;
|
| 921 |
+
}
|
| 922 |
+
// The WebSocket callback cannot throw into runTurn's promise. Retain bounded
|
| 923 |
+
// failure state through client teardown, including late duplicate results.
|
| 924 |
+
const data = payload.data;
|
| 925 |
+
if (
|
| 926 |
+
payload.sessionKey !== turn.sessionKey ||
|
| 927 |
+
!isRecord(data) ||
|
| 928 |
+
data.name !== "exec" ||
|
| 929 |
+
typeof data.toolCallId !== "string"
|
| 930 |
+
) {
|
| 931 |
+
invalid = true;
|
| 932 |
+
return;
|
| 933 |
+
}
|
| 934 |
+
if (data.phase === "start") {
|
| 935 |
+
invalid ||= turn.toolCallId !== undefined;
|
| 936 |
+
turn.toolCallId = data.toolCallId;
|
| 937 |
+
} else if (data.phase === "result") {
|
| 938 |
+
const result = isRecord(data.result) ? data.result : {};
|
| 939 |
+
const details = isRecord(result.details) ? result.details : {};
|
| 940 |
+
invalid ||=
|
| 941 |
+
turn.toolCompleted ||
|
| 942 |
+
turn.toolCallId !== data.toolCallId ||
|
| 943 |
+
data.isError !== false ||
|
| 944 |
+
details.status !== "completed" ||
|
| 945 |
+
details.exitCode !== 0 ||
|
| 946 |
+
typeof details.aggregated !== "string" ||
|
| 947 |
+
!details.aggregated.includes("openclaw-draft-proof");
|
| 948 |
+
turn.toolCompleted = true;
|
| 949 |
+
}
|
| 950 |
+
},
|
| 951 |
+
complete(runId: string, terminalReply: unknown) {
|
| 952 |
+
const turn = turns.get(runId);
|
| 953 |
+
if (
|
| 954 |
+
!turn ||
|
| 955 |
+
turn.final ||
|
| 956 |
+
!isRecord(terminalReply) ||
|
| 957 |
+
terminalReply.disposition !== "visible" ||
|
| 958 |
+
terminalReply.text !== "OPENCLAW_E2E_DRAFTPROOF"
|
| 959 |
+
) {
|
| 960 |
+
throw new Error("benchmark tool turn did not produce its expected visible final reply");
|
| 961 |
+
}
|
| 962 |
+
turn.final = true;
|
| 963 |
+
},
|
| 964 |
+
finish() {
|
| 965 |
+
if (
|
| 966 |
+
invalid ||
|
| 967 |
+
(toolEvents &&
|
| 968 |
+
[...turns.values()].some(
|
| 969 |
+
(turn) => !turn.toolCallId || !turn.toolCompleted || !turn.final,
|
| 970 |
+
))
|
| 971 |
+
) {
|
| 972 |
+
throw new Error(
|
| 973 |
+
"benchmark tool lifecycle evidence is missing, duplicated, or unsuccessful",
|
| 974 |
+
);
|
| 975 |
+
}
|
| 976 |
+
return {
|
| 977 |
+
toolTurns: toolEvents ? turns.size : 0,
|
| 978 |
+
observerModelDigestTurns: [...turns.values()].filter((turn) => turn.observer).length,
|
| 979 |
+
};
|
| 980 |
+
},
|
| 981 |
+
};
|
| 982 |
+
}
|
| 983 |
+
|
| 984 |
+
function buildConfig(
|
| 985 |
+
root: string,
|
| 986 |
+
mockPort: number,
|
| 987 |
+
concurrency: number,
|
| 988 |
+
pluginCount: number,
|
| 989 |
+
browserSessionClicks: number,
|
| 990 |
+
agentIds: string[],
|
| 991 |
+
): string {
|
| 992 |
+
const controlUiRoot = path.join(root, "control-ui");
|
| 993 |
+
mkdirSync(controlUiRoot, { recursive: true });
|
| 994 |
+
const checkoutIndex = path.join(process.cwd(), "ui", "index.html");
|
| 995 |
+
if (browserSessionClicks > 0) {
|
| 996 |
+
const builtUi = path.join(process.cwd(), "dist", "control-ui");
|
| 997 |
+
if (!existsSync(path.join(builtUi, "index.html"))) {
|
| 998 |
+
throw new Error(
|
| 999 |
+
"--browser-session-clicks requires built Control UI assets; run pnpm ui:build",
|
| 1000 |
+
);
|
| 1001 |
+
}
|
| 1002 |
+
cpSync(builtUi, controlUiRoot, { recursive: true });
|
| 1003 |
+
} else {
|
| 1004 |
+
copyFileSync(
|
| 1005 |
+
existsSync(checkoutIndex)
|
| 1006 |
+
? checkoutIndex
|
| 1007 |
+
: path.join(process.cwd(), "dist", "control-ui", "index.html"),
|
| 1008 |
+
path.join(controlUiRoot, "index.html"),
|
| 1009 |
+
);
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
const config = structuredClone(BASE_GATEWAY_BENCH_CONFIG) as Record<string, unknown>;
|
| 1013 |
+
config.gateway = {
|
| 1014 |
+
...(config.gateway as Record<string, unknown>),
|
| 1015 |
+
controlUi: { enabled: true, root: controlUiRoot },
|
| 1016 |
+
};
|
| 1017 |
+
applyMockOpenAiModelConfig(config, {
|
| 1018 |
+
mockPort,
|
| 1019 |
+
modelRef: "openai/gpt-5.6-luna",
|
| 1020 |
+
utilityModelRef: `openai/${UTILITY_MODEL_ID}`,
|
| 1021 |
+
});
|
| 1022 |
+
const agents = config.agents as Record<string, unknown>;
|
| 1023 |
+
agents.defaults = {
|
| 1024 |
+
...(agents.defaults as Record<string, unknown>),
|
| 1025 |
+
maxConcurrent: concurrency,
|
| 1026 |
+
heartbeat: { every: "0m" },
|
| 1027 |
+
};
|
| 1028 |
+
const pluginFixtures =
|
| 1029 |
+
pluginCount > 0 ? writePluginFixtures(root, { count: pluginCount }) : undefined;
|
| 1030 |
+
const agentList =
|
| 1031 |
+
agentIds.length > 1
|
| 1032 |
+
? agentIds.map((id, index) => {
|
| 1033 |
+
const workspace = path.join(root, `workspace-${id}`);
|
| 1034 |
+
mkdirSync(workspace, { recursive: true });
|
| 1035 |
+
return { id, default: index === 0, workspace };
|
| 1036 |
+
})
|
| 1037 |
+
: undefined;
|
| 1038 |
+
return writeGatewayBenchConfig(root, config, { agentList, pluginFixtures });
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
async function readGatewayProtocolVersion(entry: string): Promise<number> {
|
| 1042 |
+
const protocolPath = path.join(
|
| 1043 |
+
path.dirname(path.resolve(entry)),
|
| 1044 |
+
"gateway",
|
| 1045 |
+
"protocol",
|
| 1046 |
+
"index.js",
|
| 1047 |
+
);
|
| 1048 |
+
const protocol: unknown = await import(pathToFileURL(protocolPath).href);
|
| 1049 |
+
if (
|
| 1050 |
+
typeof protocol !== "object" ||
|
| 1051 |
+
protocol === null ||
|
| 1052 |
+
!("PROTOCOL_VERSION" in protocol) ||
|
| 1053 |
+
typeof protocol.PROTOCOL_VERSION !== "number"
|
| 1054 |
+
) {
|
| 1055 |
+
throw new Error(`Gateway protocol module is missing PROTOCOL_VERSION: ${protocolPath}`);
|
| 1056 |
+
}
|
| 1057 |
+
return protocol.PROTOCOL_VERSION;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
async function connectGateway(
|
| 1061 |
+
port: number,
|
| 1062 |
+
deadlineAt: number,
|
| 1063 |
+
protocolVersion: number,
|
| 1064 |
+
subscribeSessions = true,
|
| 1065 |
+
onEvent?: Parameters<typeof createGatewayWsClient>[0]["onEvent"],
|
| 1066 |
+
) {
|
| 1067 |
+
let requestDeadlineAt = deadlineAt;
|
| 1068 |
+
const client = createGatewayWsClient({
|
| 1069 |
+
handshakeTimeoutMs: Math.min(8_000, requireRemainingMs(deadlineAt, "connecting WebSocket")),
|
| 1070 |
+
openTimeoutMs: Math.min(8_000, requireRemainingMs(deadlineAt, "opening WebSocket")),
|
| 1071 |
+
url: `ws://127.0.0.1:${port}`,
|
| 1072 |
+
onEvent,
|
| 1073 |
+
});
|
| 1074 |
+
await client.waitOpen();
|
| 1075 |
+
|
| 1076 |
+
const requestRpc = async <T>(
|
| 1077 |
+
method: string,
|
| 1078 |
+
params: unknown,
|
| 1079 |
+
requestedTimeoutMs?: number,
|
| 1080 |
+
): Promise<T> => {
|
| 1081 |
+
const response = await client.request(
|
| 1082 |
+
method,
|
| 1083 |
+
params,
|
| 1084 |
+
Math.max(
|
| 1085 |
+
1,
|
| 1086 |
+
Math.min(
|
| 1087 |
+
requestedTimeoutMs ?? 65_000,
|
| 1088 |
+
requireRemainingMs(requestDeadlineAt, `waiting for ${method}`),
|
| 1089 |
+
),
|
| 1090 |
+
),
|
| 1091 |
+
);
|
| 1092 |
+
if (!response.ok) {
|
| 1093 |
+
const message =
|
| 1094 |
+
response.error && typeof response.error === "object" && "message" in response.error
|
| 1095 |
+
? String(response.error.message)
|
| 1096 |
+
: JSON.stringify(response.error);
|
| 1097 |
+
throw new Error(`${method} failed: ${message}`);
|
| 1098 |
+
}
|
| 1099 |
+
return response.payload as T;
|
| 1100 |
+
};
|
| 1101 |
+
|
| 1102 |
+
await requestRpc("connect", {
|
| 1103 |
+
minProtocol: protocolVersion,
|
| 1104 |
+
maxProtocol: protocolVersion,
|
| 1105 |
+
client: {
|
| 1106 |
+
id: "gateway-client",
|
| 1107 |
+
displayName: "gateway-concurrency-benchmark",
|
| 1108 |
+
version: "1.0.0",
|
| 1109 |
+
platform: process.platform,
|
| 1110 |
+
mode: "backend",
|
| 1111 |
+
},
|
| 1112 |
+
role: "operator",
|
| 1113 |
+
scopes: ["operator.read", "operator.write", "operator.admin"],
|
| 1114 |
+
caps: [],
|
| 1115 |
+
});
|
| 1116 |
+
if (subscribeSessions) {
|
| 1117 |
+
await requestRpc("sessions.subscribe", {});
|
| 1118 |
+
}
|
| 1119 |
+
return {
|
| 1120 |
+
close: client.close,
|
| 1121 |
+
waitClosed: () =>
|
| 1122 |
+
new Promise<void>((resolve, reject) => {
|
| 1123 |
+
if (client.ws.readyState === client.ws.CLOSED) {
|
| 1124 |
+
resolve();
|
| 1125 |
+
return;
|
| 1126 |
+
}
|
| 1127 |
+
const onClose = () => {
|
| 1128 |
+
clearTimeout(timer);
|
| 1129 |
+
resolve();
|
| 1130 |
+
};
|
| 1131 |
+
const timer = setTimeout(() => {
|
| 1132 |
+
client.ws.off("close", onClose);
|
| 1133 |
+
reject(new Error("benchmark event client did not close"));
|
| 1134 |
+
}, HTTP_TIMEOUT_MS);
|
| 1135 |
+
timer.unref();
|
| 1136 |
+
client.ws.once("close", onClose);
|
| 1137 |
+
}),
|
| 1138 |
+
request: requestRpc,
|
| 1139 |
+
setDeadlineAt: (value: number) => {
|
| 1140 |
+
requestDeadlineAt = value;
|
| 1141 |
+
},
|
| 1142 |
+
};
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
function readGatewayProcessRssMb(pid: number | undefined): number | null {
|
| 1146 |
+
if (!pid) {
|
| 1147 |
+
return null;
|
| 1148 |
+
}
|
| 1149 |
+
if (process.platform !== "linux") {
|
| 1150 |
+
return readProcessRssMb(pid);
|
| 1151 |
+
}
|
| 1152 |
+
try {
|
| 1153 |
+
const status = readFileSync(`/proc/${pid}/status`, "utf8");
|
| 1154 |
+
const match = /^VmRSS:\s+(\d+)\s+kB$/mu.exec(status);
|
| 1155 |
+
const rssKb = match ? Number(match[1]) : Number.NaN;
|
| 1156 |
+
return Number.isFinite(rssKb) && rssKb > 0 ? rssKb / 1024 : null;
|
| 1157 |
+
} catch {
|
| 1158 |
+
return null;
|
| 1159 |
+
}
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
async function timeRpcProbe(
|
| 1163 |
+
rpc: GatewayRpc,
|
| 1164 |
+
method: string,
|
| 1165 |
+
params: unknown,
|
| 1166 |
+
runStartedAt: number,
|
| 1167 |
+
): Promise<TimedProbe> {
|
| 1168 |
+
const startedAt = performance.now();
|
| 1169 |
+
try {
|
| 1170 |
+
await rpc(method, params);
|
| 1171 |
+
return {
|
| 1172 |
+
atMs: startedAt - runStartedAt,
|
| 1173 |
+
error: null,
|
| 1174 |
+
latencyMs: performance.now() - startedAt,
|
| 1175 |
+
ok: true,
|
| 1176 |
+
};
|
| 1177 |
+
} catch (error) {
|
| 1178 |
+
return {
|
| 1179 |
+
atMs: startedAt - runStartedAt,
|
| 1180 |
+
error: describeProbeError(error),
|
| 1181 |
+
latencyMs: performance.now() - startedAt,
|
| 1182 |
+
ok: false,
|
| 1183 |
+
};
|
| 1184 |
+
}
|
| 1185 |
+
}
|
| 1186 |
+
|
| 1187 |
+
async function runTurn(
|
| 1188 |
+
rpc: GatewayRpc,
|
| 1189 |
+
index: number,
|
| 1190 |
+
deadlineAt: number,
|
| 1191 |
+
toolEvents = false,
|
| 1192 |
+
options?: {
|
| 1193 |
+
onStarted?: () => void;
|
| 1194 |
+
sessionKey?: string;
|
| 1195 |
+
evidence?: ReturnType<typeof createTurnEvidence>;
|
| 1196 |
+
},
|
| 1197 |
+
): Promise<void> {
|
| 1198 |
+
const requestedRunId = randomUUID();
|
| 1199 |
+
const sessionKey = options?.sessionKey ?? `agent:main:gateway-concurrency-${index + 1}`;
|
| 1200 |
+
const evidence = options?.evidence ?? createTurnEvidence(toolEvents);
|
| 1201 |
+
evidence.register(requestedRunId, sessionKey);
|
| 1202 |
+
const started = await rpc<{ runId?: string; status?: string }>("agent", {
|
| 1203 |
+
sessionKey,
|
| 1204 |
+
message: toolEvents
|
| 1205 |
+
? `OPENCLAW_E2E_DRAFTPROOF benchmark tool stream ${index + 1}.`
|
| 1206 |
+
: `Reply with benchmark stream ${index + 1}.`,
|
| 1207 |
+
deliver: false,
|
| 1208 |
+
idempotencyKey: requestedRunId,
|
| 1209 |
+
});
|
| 1210 |
+
options?.onStarted?.();
|
| 1211 |
+
if (options?.evidence && started.runId !== undefined && started.runId !== requestedRunId) {
|
| 1212 |
+
throw new Error("agent returned a different benchmark run identity");
|
| 1213 |
+
}
|
| 1214 |
+
if (started.status === "ok" && !toolEvents) {
|
| 1215 |
+
return;
|
| 1216 |
+
}
|
| 1217 |
+
if (started.status !== "accepted" && started.status !== "ok") {
|
| 1218 |
+
throw new Error(`agent ${index + 1} was not accepted: ${JSON.stringify(started)}`);
|
| 1219 |
+
}
|
| 1220 |
+
const remaining = requireRemainingMs(deadlineAt, `waiting for agent ${index + 1} completion`);
|
| 1221 |
+
// Agent waits share the load deadline; a shorter observation cap would abort
|
| 1222 |
+
// an active turn while the benchmark still has time to measure completion.
|
| 1223 |
+
const waitTimeoutMs = Math.max(0, Math.floor(remaining - AGENT_WAIT_RPC_GRACE_MS));
|
| 1224 |
+
const rpcTimeoutMs = Math.max(1, Math.ceil(remaining));
|
| 1225 |
+
const runId = started.runId ?? requestedRunId;
|
| 1226 |
+
const completed = await rpc<{ runId?: string; status?: string; terminalReply?: unknown }>(
|
| 1227 |
+
"agent.wait",
|
| 1228 |
+
{
|
| 1229 |
+
runId,
|
| 1230 |
+
timeoutMs: waitTimeoutMs,
|
| 1231 |
+
},
|
| 1232 |
+
rpcTimeoutMs,
|
| 1233 |
+
);
|
| 1234 |
+
if (completed.status !== "ok") {
|
| 1235 |
+
throw new Error(`agent ${index + 1} did not complete: ${JSON.stringify(completed)}`);
|
| 1236 |
+
}
|
| 1237 |
+
if (completed.runId !== runId) {
|
| 1238 |
+
throw new Error("agent.wait returned a different or missing benchmark run identity");
|
| 1239 |
+
}
|
| 1240 |
+
if (toolEvents) {
|
| 1241 |
+
evidence.complete(requestedRunId, completed.terminalReply);
|
| 1242 |
+
}
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
async function runSessionTurns(
|
| 1246 |
+
rpc: GatewayRpc,
|
| 1247 |
+
index: number,
|
| 1248 |
+
deadlineAt: number,
|
| 1249 |
+
options: {
|
| 1250 |
+
onStarted?: () => void;
|
| 1251 |
+
sessionKey: string;
|
| 1252 |
+
toolEvents: boolean;
|
| 1253 |
+
turnsPerSession: number;
|
| 1254 |
+
evidence?: ReturnType<typeof createTurnEvidence>;
|
| 1255 |
+
},
|
| 1256 |
+
): Promise<number> {
|
| 1257 |
+
for (let turn = 0; turn < options.turnsPerSession; turn += 1) {
|
| 1258 |
+
requireRemainingMs(deadlineAt, `starting session ${index + 1} turn ${turn + 1}`);
|
| 1259 |
+
await runTurn(rpc, index * options.turnsPerSession + turn, deadlineAt, options.toolEvents, {
|
| 1260 |
+
sessionKey: options.sessionKey,
|
| 1261 |
+
onStarted: turn === 0 ? options.onStarted : undefined,
|
| 1262 |
+
evidence: options.evidence,
|
| 1263 |
+
});
|
| 1264 |
+
}
|
| 1265 |
+
return options.turnsPerSession;
|
| 1266 |
+
}
|
| 1267 |
+
|
| 1268 |
+
async function sampleGateway(params: {
|
| 1269 |
+
deadlineAt: number;
|
| 1270 |
+
port: number;
|
| 1271 |
+
rpc: GatewayRpc;
|
| 1272 |
+
runStartedAt: number;
|
| 1273 |
+
serial?: boolean;
|
| 1274 |
+
}): Promise<GatewaySample> {
|
| 1275 |
+
const atMs = performance.now() - params.runStartedAt;
|
| 1276 |
+
const safeHttpProbe = async (pathValue: string, accept: string) => {
|
| 1277 |
+
const startedAt = performance.now();
|
| 1278 |
+
try {
|
| 1279 |
+
return {
|
| 1280 |
+
...(await requestHttp({
|
| 1281 |
+
accept,
|
| 1282 |
+
deadlineAt: params.deadlineAt,
|
| 1283 |
+
path: pathValue,
|
| 1284 |
+
port: params.port,
|
| 1285 |
+
})),
|
| 1286 |
+
error: null,
|
| 1287 |
+
ok: true,
|
| 1288 |
+
};
|
| 1289 |
+
} catch (error) {
|
| 1290 |
+
return {
|
| 1291 |
+
body: "",
|
| 1292 |
+
error: describeProbeError(error),
|
| 1293 |
+
latencyMs: performance.now() - startedAt,
|
| 1294 |
+
ok: false,
|
| 1295 |
+
status: 0,
|
| 1296 |
+
};
|
| 1297 |
+
}
|
| 1298 |
+
};
|
| 1299 |
+
const probeReadyz = () => safeHttpProbe("/readyz", "application/json");
|
| 1300 |
+
const probeControlUi = () => safeHttpProbe("/", "text/html");
|
| 1301 |
+
const probeSessions = async () => {
|
| 1302 |
+
const startedAt = performance.now();
|
| 1303 |
+
try {
|
| 1304 |
+
const payload = await params.rpc(
|
| 1305 |
+
"sessions.list",
|
| 1306 |
+
{},
|
| 1307 |
+
Math.min(HTTP_TIMEOUT_MS, requireRemainingMs(params.deadlineAt, "probing sessions.list")),
|
| 1308 |
+
);
|
| 1309 |
+
return { error: null, latencyMs: performance.now() - startedAt, ok: true, payload };
|
| 1310 |
+
} catch (error) {
|
| 1311 |
+
return {
|
| 1312 |
+
error: describeProbeError(error),
|
| 1313 |
+
latencyMs: performance.now() - startedAt,
|
| 1314 |
+
ok: false,
|
| 1315 |
+
payload: null,
|
| 1316 |
+
};
|
| 1317 |
+
}
|
| 1318 |
+
};
|
| 1319 |
+
const [readyz, controlUi, sessions] = params.serial
|
| 1320 |
+
? [await probeReadyz(), await probeControlUi(), await probeSessions()]
|
| 1321 |
+
: await Promise.all([probeReadyz(), probeControlUi(), probeSessions()]);
|
| 1322 |
+
const readyBody = (() => {
|
| 1323 |
+
if (readyz.status !== 200) {
|
| 1324 |
+
return {};
|
| 1325 |
+
}
|
| 1326 |
+
try {
|
| 1327 |
+
return JSON.parse(readyz.body) as { eventLoop?: Record<string, unknown> };
|
| 1328 |
+
} catch {
|
| 1329 |
+
return {};
|
| 1330 |
+
}
|
| 1331 |
+
})();
|
| 1332 |
+
const eventLoop = readyBody.eventLoop;
|
| 1333 |
+
return {
|
| 1334 |
+
controlUi: {
|
| 1335 |
+
atMs,
|
| 1336 |
+
error:
|
| 1337 |
+
controlUi.error ??
|
| 1338 |
+
(controlUi.status === 200 && !controlUi.body.includes("<html")
|
| 1339 |
+
? "response body did not contain <html"
|
| 1340 |
+
: null),
|
| 1341 |
+
latencyMs: controlUi.latencyMs,
|
| 1342 |
+
ok: controlUi.ok && controlUi.status === 200 && controlUi.body.includes("<html"),
|
| 1343 |
+
status: controlUi.status,
|
| 1344 |
+
},
|
| 1345 |
+
readyz: {
|
| 1346 |
+
atMs,
|
| 1347 |
+
error: readyz.error,
|
| 1348 |
+
latencyMs: readyz.latencyMs,
|
| 1349 |
+
ok: readyz.ok && readyz.status === 200,
|
| 1350 |
+
status: readyz.status,
|
| 1351 |
+
degraded: typeof eventLoop?.degraded === "boolean" ? eventLoop.degraded : null,
|
| 1352 |
+
degradedSinceMs: asFiniteNumber(eventLoop?.degradedSinceMs) ?? null,
|
| 1353 |
+
delayP99Ms: asFiniteNumber(eventLoop?.delayP99Ms) ?? null,
|
| 1354 |
+
delayMaxMs: asFiniteNumber(eventLoop?.delayMaxMs) ?? null,
|
| 1355 |
+
utilization: asFiniteNumber(eventLoop?.utilization) ?? null,
|
| 1356 |
+
cpuCoreRatio: asFiniteNumber(eventLoop?.cpuCoreRatio) ?? null,
|
| 1357 |
+
},
|
| 1358 |
+
sessionsList: {
|
| 1359 |
+
atMs,
|
| 1360 |
+
error: sessions.error,
|
| 1361 |
+
latencyMs: sessions.latencyMs,
|
| 1362 |
+
ok: sessions.ok,
|
| 1363 |
+
},
|
| 1364 |
+
};
|
| 1365 |
+
}
|
| 1366 |
+
|
| 1367 |
+
async function warmGatewayProbes(params: {
|
| 1368 |
+
deadlineAt: number;
|
| 1369 |
+
sample: (deadlineAt: number) => Promise<GatewaySample>;
|
| 1370 |
+
retryDelayMs?: number;
|
| 1371 |
+
targetMs?: number;
|
| 1372 |
+
}): Promise<{ durationMs: number; samples: GatewaySample[] }> {
|
| 1373 |
+
const startedAt = performance.now();
|
| 1374 |
+
const samples: GatewaySample[] = [];
|
| 1375 |
+
const targetMs = params.targetMs ?? PROBE_WARMUP_TARGET_MS;
|
| 1376 |
+
while (remainingMs(params.deadlineAt) > 0) {
|
| 1377 |
+
const sample = await params.sample(params.deadlineAt);
|
| 1378 |
+
samples.push(sample);
|
| 1379 |
+
const healthy = sample.readyz.ok && sample.sessionsList.ok && sample.controlUi.ok;
|
| 1380 |
+
const fast =
|
| 1381 |
+
Math.max(
|
| 1382 |
+
sample.readyz.latencyMs,
|
| 1383 |
+
sample.sessionsList.latencyMs,
|
| 1384 |
+
sample.controlUi.latencyMs,
|
| 1385 |
+
) <= targetMs;
|
| 1386 |
+
const eventLoopSettled = sample.readyz.degraded !== true;
|
| 1387 |
+
if (healthy && fast && eventLoopSettled) {
|
| 1388 |
+
return { durationMs: performance.now() - startedAt, samples };
|
| 1389 |
+
}
|
| 1390 |
+
await delay(
|
| 1391 |
+
Math.min(params.retryDelayMs ?? PROBE_WARMUP_RETRY_DELAY_MS, remainingMs(params.deadlineAt)),
|
| 1392 |
+
);
|
| 1393 |
+
}
|
| 1394 |
+
const lastSample = samples.at(-1);
|
| 1395 |
+
throw new Error(
|
| 1396 |
+
lastSample
|
| 1397 |
+
? formatProbeFailure(lastSample)
|
| 1398 |
+
: "gateway probes did not run before the warmup deadline",
|
| 1399 |
+
);
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
async function runProbeRounds(params: {
|
| 1403 |
+
rounds?: number;
|
| 1404 |
+
deadlineAt: number;
|
| 1405 |
+
cadenceMs: number;
|
| 1406 |
+
cadenceFrom: "start" | "completion";
|
| 1407 |
+
runFirst: boolean;
|
| 1408 |
+
shouldContinue: () => boolean;
|
| 1409 |
+
stopped: () => boolean;
|
| 1410 |
+
runRound: (index: number) => Promise<void>;
|
| 1411 |
+
}): Promise<number> {
|
| 1412 |
+
let completed = 0;
|
| 1413 |
+
const hasWork = () =>
|
| 1414 |
+
!params.stopped() &&
|
| 1415 |
+
(params.rounds === undefined
|
| 1416 |
+
? (completed === 0 && params.runFirst) || params.shouldContinue()
|
| 1417 |
+
: completed < params.rounds);
|
| 1418 |
+
while (hasWork()) {
|
| 1419 |
+
requireRemainingMs(params.deadlineAt, "starting gateway probe round");
|
| 1420 |
+
const startedAt = performance.now();
|
| 1421 |
+
await params.runRound(completed);
|
| 1422 |
+
completed += 1;
|
| 1423 |
+
if (!hasWork()) {
|
| 1424 |
+
break;
|
| 1425 |
+
}
|
| 1426 |
+
const elapsed = params.cadenceFrom === "start" ? performance.now() - startedAt : 0;
|
| 1427 |
+
await delay(
|
| 1428 |
+
Math.min(
|
| 1429 |
+
Math.max(0, params.cadenceMs - elapsed),
|
| 1430 |
+
requireRemainingMs(params.deadlineAt, "pacing gateway probes"),
|
| 1431 |
+
),
|
| 1432 |
+
);
|
| 1433 |
+
}
|
| 1434 |
+
return completed;
|
| 1435 |
+
}
|
| 1436 |
+
|
| 1437 |
+
async function runGatewaySample(options: {
|
| 1438 |
+
agentCount: number;
|
| 1439 |
+
browserHistoryMessages: number;
|
| 1440 |
+
browserSessionClicks: number;
|
| 1441 |
+
cadenceMs: number;
|
| 1442 |
+
concurrency: number;
|
| 1443 |
+
controlPlane: boolean;
|
| 1444 |
+
deadlineAt: number;
|
| 1445 |
+
diagnosticsTimeline: boolean;
|
| 1446 |
+
entry: string;
|
| 1447 |
+
cpuProfDir?: string;
|
| 1448 |
+
loadCpuProfDir?: string;
|
| 1449 |
+
heapProfDir?: string;
|
| 1450 |
+
historyBurst: number;
|
| 1451 |
+
historyClients: number;
|
| 1452 |
+
historyMessages: number;
|
| 1453 |
+
historyMessageChars: number;
|
| 1454 |
+
pluginCount: number;
|
| 1455 |
+
probeRounds?: number;
|
| 1456 |
+
sessionCount: number;
|
| 1457 |
+
sessionUpdateClients: number;
|
| 1458 |
+
sessionUpdates: number;
|
| 1459 |
+
streamChunkDelayMs: number;
|
| 1460 |
+
subscribers: number;
|
| 1461 |
+
timeoutMs: number;
|
| 1462 |
+
toolEvents: boolean;
|
| 1463 |
+
turnsPerSession: number;
|
| 1464 |
+
visibleObserver: boolean;
|
| 1465 |
+
workspaceFanout: boolean;
|
| 1466 |
+
}): Promise<BenchmarkRun> {
|
| 1467 |
+
const root = mkdtempSync(path.join(tmpdir(), "openclaw-gateway-concurrency-"));
|
| 1468 |
+
const [port, mockPort] = await Promise.all([getFreePort(), getFreePort()]);
|
| 1469 |
+
const runStartedAt = performance.now();
|
| 1470 |
+
const agentIds = Array.from({ length: options.agentCount }, (_, index) =>
|
| 1471 |
+
index === 0 ? "main" : `bench-agent-${index + 1}`,
|
| 1472 |
+
);
|
| 1473 |
+
const timelinePath = path.join(root, "diagnostics-timeline.jsonl");
|
| 1474 |
+
const responseControlPath = path.join(root, "mock-provider-responses.json");
|
| 1475 |
+
const heapProfilePath = options.heapProfDir
|
| 1476 |
+
? path.resolve(options.heapProfDir, `gateway-load-${randomUUID()}.heapprofile`)
|
| 1477 |
+
: undefined;
|
| 1478 |
+
const loadCpuProfilePath = options.loadCpuProfDir
|
| 1479 |
+
? path.resolve(options.loadCpuProfDir, `gateway-load-${randomUUID()}.cpuprofile`)
|
| 1480 |
+
: undefined;
|
| 1481 |
+
const protocolVersion = await readGatewayProtocolVersion(options.entry);
|
| 1482 |
+
let gateway: ChildProcess | undefined;
|
| 1483 |
+
let mockProvider: ChildProcessWithoutNullStreams | undefined;
|
| 1484 |
+
let client: Awaited<ReturnType<typeof connectGateway>> | undefined;
|
| 1485 |
+
let browserProbe: Awaited<ReturnType<typeof startGatewayBrowserProbe>> | undefined;
|
| 1486 |
+
const auxiliaryClients: Array<Awaited<ReturnType<typeof connectGateway>>> = [];
|
| 1487 |
+
let probesStopped = false;
|
| 1488 |
+
const probeJobs: Promise<unknown>[] = [];
|
| 1489 |
+
let gatewayOutput = { readOutput: () => "", readStderrTail: () => "" };
|
| 1490 |
+
let mockOutput = { readOutput: () => "", readStderrTail: () => "" };
|
| 1491 |
+
let result: Omit<BenchmarkRun, "mockRequests" | "turnEvidence">;
|
| 1492 |
+
const mockCheckpoints: MockRequestSnapshot[] = [];
|
| 1493 |
+
const turnEvidence = createTurnEvidence(options.toolEvents);
|
| 1494 |
+
let gatewayExit: Awaited<ReturnType<typeof stopChild>> | undefined;
|
| 1495 |
+
let gatewayExitEvent: GatewayChildExit | undefined;
|
| 1496 |
+
let gatewayCloseEvent: GatewayChildExit | undefined;
|
| 1497 |
+
const readGatewayProcess = () => ({
|
| 1498 |
+
pid: gateway?.pid,
|
| 1499 |
+
exitCode: gateway?.exitCode,
|
| 1500 |
+
signalCode: gateway?.signalCode,
|
| 1501 |
+
exitEvent: gatewayExitEvent,
|
| 1502 |
+
closeEvent: gatewayCloseEvent,
|
| 1503 |
+
});
|
| 1504 |
+
let timelineWindow: { from: number; through: number } | undefined;
|
| 1505 |
+
|
| 1506 |
+
try {
|
| 1507 |
+
try {
|
| 1508 |
+
const configPath = buildConfig(
|
| 1509 |
+
root,
|
| 1510 |
+
mockPort,
|
| 1511 |
+
options.concurrency,
|
| 1512 |
+
options.pluginCount,
|
| 1513 |
+
options.browserSessionClicks,
|
| 1514 |
+
agentIds,
|
| 1515 |
+
);
|
| 1516 |
+
writeFileSync(
|
| 1517 |
+
responseControlPath,
|
| 1518 |
+
JSON.stringify({
|
| 1519 |
+
models: {
|
| 1520 |
+
[UTILITY_MODEL_ID]: {
|
| 1521 |
+
text: JSON.stringify({
|
| 1522 |
+
headline: "Synthetic benchmark work is progressing.",
|
| 1523 |
+
assessment: OBSERVER_ASSESSMENT,
|
| 1524 |
+
health: "on-track",
|
| 1525 |
+
}),
|
| 1526 |
+
},
|
| 1527 |
+
},
|
| 1528 |
+
}),
|
| 1529 |
+
);
|
| 1530 |
+
mockProvider = spawn(process.execPath, ["scripts/e2e/mock-openai-server.mjs"], {
|
| 1531 |
+
cwd: process.cwd(),
|
| 1532 |
+
detached: process.platform !== "win32",
|
| 1533 |
+
env: {
|
| 1534 |
+
LANG: process.env.LANG ?? "en_US.UTF-8",
|
| 1535 |
+
PATH: process.env.PATH,
|
| 1536 |
+
MOCK_PORT: String(mockPort),
|
| 1537 |
+
MOCK_RESPONSE_CONTROL: responseControlPath,
|
| 1538 |
+
MOCK_RESPONSE_CHUNK_DELAY_MS: String(options.streamChunkDelayMs),
|
| 1539 |
+
SUCCESS_MARKER: "OpenClaw gateway concurrency benchmark streaming response.",
|
| 1540 |
+
},
|
| 1541 |
+
});
|
| 1542 |
+
mockOutput = captureChildOutput(mockProvider);
|
| 1543 |
+
await waitForMockServer(mockPort, options.deadlineAt);
|
| 1544 |
+
mockCheckpoints.push(await readMockRequests(mockPort, options.deadlineAt));
|
| 1545 |
+
|
| 1546 |
+
if (options.cpuProfDir) {
|
| 1547 |
+
mkdirSync(options.cpuProfDir, { recursive: true });
|
| 1548 |
+
}
|
| 1549 |
+
const gatewayArgs = buildGatewayBenchChildArgs(options.entry, port);
|
| 1550 |
+
gatewayArgs.unshift(
|
| 1551 |
+
"--import",
|
| 1552 |
+
new URL("./lib/gateway-bench-profile-preload.ts", import.meta.url).href,
|
| 1553 |
+
);
|
| 1554 |
+
if (heapProfilePath || loadCpuProfilePath) {
|
| 1555 |
+
for (const profilePath of [heapProfilePath, loadCpuProfilePath]) {
|
| 1556 |
+
if (profilePath) {
|
| 1557 |
+
mkdirSync(path.dirname(profilePath), { recursive: true });
|
| 1558 |
+
}
|
| 1559 |
+
}
|
| 1560 |
+
}
|
| 1561 |
+
gateway = spawn(
|
| 1562 |
+
process.execPath,
|
| 1563 |
+
options.cpuProfDir
|
| 1564 |
+
? ["--cpu-prof", `--cpu-prof-dir=${options.cpuProfDir}`, ...gatewayArgs]
|
| 1565 |
+
: gatewayArgs,
|
| 1566 |
+
{
|
| 1567 |
+
cwd: process.cwd(),
|
| 1568 |
+
detached: process.platform !== "win32",
|
| 1569 |
+
stdio: ["pipe", "pipe", "pipe", "ipc"],
|
| 1570 |
+
env: {
|
| 1571 |
+
...createGatewayBenchEnv(root, configPath, {
|
| 1572 |
+
caseEnv: {
|
| 1573 |
+
...(options.diagnosticsTimeline
|
| 1574 |
+
? {
|
| 1575 |
+
OPENCLAW_DIAGNOSTICS: "timeline",
|
| 1576 |
+
OPENCLAW_DIAGNOSTICS_TIMELINE_PATH: timelinePath,
|
| 1577 |
+
}
|
| 1578 |
+
: {}),
|
| 1579 |
+
OPENCLAW_SKIP_CHANNELS: "1",
|
| 1580 |
+
},
|
| 1581 |
+
}),
|
| 1582 |
+
OPENAI_API_KEY: "gateway-concurrency-benchmark",
|
| 1583 |
+
},
|
| 1584 |
+
},
|
| 1585 |
+
);
|
| 1586 |
+
gateway.once("exit", (exitCode, signal) => {
|
| 1587 |
+
gatewayExitEvent = {
|
| 1588 |
+
atMonotonicMicros: Number(process.hrtime.bigint() / 1_000n),
|
| 1589 |
+
exitCode,
|
| 1590 |
+
signal,
|
| 1591 |
+
};
|
| 1592 |
+
});
|
| 1593 |
+
gateway.once("close", (exitCode, signal) => {
|
| 1594 |
+
gatewayCloseEvent = {
|
| 1595 |
+
atMonotonicMicros: Number(process.hrtime.bigint() / 1_000n),
|
| 1596 |
+
exitCode,
|
| 1597 |
+
signal,
|
| 1598 |
+
};
|
| 1599 |
+
});
|
| 1600 |
+
gatewayOutput = captureChildOutput(gateway);
|
| 1601 |
+
const ready = await waitForInitialProbe({
|
| 1602 |
+
deadlineAt: options.deadlineAt,
|
| 1603 |
+
isDone: () => gateway?.exitCode != null || gateway?.signalCode != null,
|
| 1604 |
+
path: "/readyz",
|
| 1605 |
+
port,
|
| 1606 |
+
startAt: runStartedAt,
|
| 1607 |
+
});
|
| 1608 |
+
if (ready.status !== 200) {
|
| 1609 |
+
throw new Error(`gateway did not become ready\n${gatewayOutput.readOutput()}`);
|
| 1610 |
+
}
|
| 1611 |
+
await waitForGatewayDispatchReady(gatewayOutput.readOutput, options.deadlineAt);
|
| 1612 |
+
client = await connectGateway(
|
| 1613 |
+
port,
|
| 1614 |
+
options.deadlineAt,
|
| 1615 |
+
protocolVersion,
|
| 1616 |
+
true,
|
| 1617 |
+
turnEvidence.onEvent,
|
| 1618 |
+
);
|
| 1619 |
+
const rpc = client.request;
|
| 1620 |
+
if (options.visibleObserver) {
|
| 1621 |
+
await rpc("sessions.observer.visibility", { visible: true });
|
| 1622 |
+
}
|
| 1623 |
+
// The first authenticated RPC lazily imports the server-method graph. It measured 6.9s
|
| 1624 |
+
// on an idle M4 Pro (previously 18.4s) and crossed 20s on Linux; hot probes took 15-40ms.
|
| 1625 |
+
// Keep that cold work out of the load-phase deadline and latency distributions.
|
| 1626 |
+
const probeWarmupDeadlineAt = performance.now() + PROBE_WARMUP_TIMEOUT_MS;
|
| 1627 |
+
client.setDeadlineAt(probeWarmupDeadlineAt);
|
| 1628 |
+
const probeWarmup = await warmGatewayProbes({
|
| 1629 |
+
deadlineAt: probeWarmupDeadlineAt,
|
| 1630 |
+
sample: (deadlineAt) =>
|
| 1631 |
+
sampleGateway({
|
| 1632 |
+
deadlineAt,
|
| 1633 |
+
port,
|
| 1634 |
+
rpc,
|
| 1635 |
+
runStartedAt,
|
| 1636 |
+
}),
|
| 1637 |
+
});
|
| 1638 |
+
const setupDeadlineAt = performance.now() + options.timeoutMs;
|
| 1639 |
+
const setupStartedAt = performance.now();
|
| 1640 |
+
mockCheckpoints.push(await readMockRequests(mockPort, setupDeadlineAt));
|
| 1641 |
+
client.setDeadlineAt(setupDeadlineAt);
|
| 1642 |
+
const sessionCount = Math.max(options.concurrency, options.sessionCount);
|
| 1643 |
+
const prepareSessions =
|
| 1644 |
+
options.agentCount > 1 ||
|
| 1645 |
+
options.browserSessionClicks > 0 ||
|
| 1646 |
+
options.workspaceFanout ||
|
| 1647 |
+
options.sessionCount > 0 ||
|
| 1648 |
+
options.historyClients > 0 ||
|
| 1649 |
+
options.historyMessages > 0 ||
|
| 1650 |
+
options.sessionUpdates > 0 ||
|
| 1651 |
+
options.subscribers > 0;
|
| 1652 |
+
const sessionAgents = Array.from(
|
| 1653 |
+
{ length: sessionCount },
|
| 1654 |
+
(_, index) => agentIds[index % agentIds.length]!,
|
| 1655 |
+
);
|
| 1656 |
+
const sessionKeys = sessionAgents.map(
|
| 1657 |
+
(agentId, index) => `agent:${agentId}:gateway-concurrency-${index + 1}`,
|
| 1658 |
+
);
|
| 1659 |
+
const sessionSeedStartedAt = performance.now();
|
| 1660 |
+
if (prepareSessions) {
|
| 1661 |
+
let nextSessionIndex = 0;
|
| 1662 |
+
let seededSessionCount = 0;
|
| 1663 |
+
await Promise.all(
|
| 1664 |
+
Array.from({ length: Math.min(MAX_SESSION_SEED_CONCURRENCY, sessionCount) }, async () => {
|
| 1665 |
+
for (;;) {
|
| 1666 |
+
const index = nextSessionIndex++;
|
| 1667 |
+
const sessionKey = sessionKeys[index];
|
| 1668 |
+
if (!sessionKey) {
|
| 1669 |
+
return;
|
| 1670 |
+
}
|
| 1671 |
+
const workspaceDir = options.workspaceFanout
|
| 1672 |
+
? path.join(root, `workspace-${index + 1}`)
|
| 1673 |
+
: undefined;
|
| 1674 |
+
if (workspaceDir) {
|
| 1675 |
+
mkdirSync(workspaceDir, { recursive: true });
|
| 1676 |
+
}
|
| 1677 |
+
await rpc("sessions.create", {
|
| 1678 |
+
key: sessionKey,
|
| 1679 |
+
agentId: sessionAgents[index],
|
| 1680 |
+
...(workspaceDir ? { cwd: workspaceDir } : {}),
|
| 1681 |
+
});
|
| 1682 |
+
for (
|
| 1683 |
+
let messageIndex = 0;
|
| 1684 |
+
messageIndex < options.historyMessages;
|
| 1685 |
+
messageIndex += 1
|
| 1686 |
+
) {
|
| 1687 |
+
const marker = `Synthetic history ${index + 1}/${messageIndex + 1}. `;
|
| 1688 |
+
const message = marker
|
| 1689 |
+
.repeat(Math.ceil(options.historyMessageChars / marker.length))
|
| 1690 |
+
.slice(0, options.historyMessageChars);
|
| 1691 |
+
await rpc("chat.inject", { sessionKey, message });
|
| 1692 |
+
}
|
| 1693 |
+
seededSessionCount += 1;
|
| 1694 |
+
if (sessionCount >= 250 && seededSessionCount % 250 === 0) {
|
| 1695 |
+
console.error(
|
| 1696 |
+
`[bench-gateway-concurrency] seeded ${seededSessionCount}/${sessionCount} sessions`,
|
| 1697 |
+
);
|
| 1698 |
+
}
|
| 1699 |
+
}
|
| 1700 |
+
}),
|
| 1701 |
+
);
|
| 1702 |
+
}
|
| 1703 |
+
const sessionSeedDurationMs = performance.now() - sessionSeedStartedAt;
|
| 1704 |
+
const browserTargets: BrowserSessionTarget[] = [];
|
| 1705 |
+
const browserHistoryMessages = options.browserHistoryMessages;
|
| 1706 |
+
if (options.browserSessionClicks > 0) {
|
| 1707 |
+
for (let index = 0; index <= options.browserSessionClicks; index += 1) {
|
| 1708 |
+
const target = {
|
| 1709 |
+
key: `agent:main:sidebar-click-${index}`,
|
| 1710 |
+
marker: `Sidebar click history ${index}.`,
|
| 1711 |
+
};
|
| 1712 |
+
await rpc("sessions.create", { key: target.key, agentId: "main" });
|
| 1713 |
+
for (let message = 0; message < browserHistoryMessages; message += 1) {
|
| 1714 |
+
const prefix = `## Synthetic message ${message + 1}\n\n${target.marker}\n\n`;
|
| 1715 |
+
const messageText =
|
| 1716 |
+
prefix +
|
| 1717 |
+
"Synthetic **benchmark** paragraph. "
|
| 1718 |
+
.repeat(Math.ceil(options.historyMessageChars / 35))
|
| 1719 |
+
.slice(0, Math.max(0, options.historyMessageChars - prefix.length));
|
| 1720 |
+
await rpc("chat.inject", { sessionKey: target.key, message: messageText });
|
| 1721 |
+
}
|
| 1722 |
+
browserTargets.push(target);
|
| 1723 |
+
}
|
| 1724 |
+
browserProbe = await startGatewayBrowserProbe({
|
| 1725 |
+
port,
|
| 1726 |
+
timeoutMs: requireRemainingMs(setupDeadlineAt, "opening browser"),
|
| 1727 |
+
initial: browserTargets[0]!,
|
| 1728 |
+
visibleSessions: options.concurrency + browserTargets.length,
|
| 1729 |
+
});
|
| 1730 |
+
}
|
| 1731 |
+
let browserInventory: NonNullable<BenchmarkRun["browser"]>["inventory"] | undefined;
|
| 1732 |
+
if (browserProbe) {
|
| 1733 |
+
const [unarchived, retained] = await Promise.all([
|
| 1734 |
+
rpc<{ totalCount: number }>("sessions.list", { limit: 1, archived: false }),
|
| 1735 |
+
rpc<{ totalCount: number }>("sessions.list", { limit: 1, archived: "all" }),
|
| 1736 |
+
]);
|
| 1737 |
+
browserInventory = {
|
| 1738 |
+
seededLoadSessions: sessionCount,
|
| 1739 |
+
seededClickSessions: browserTargets.length,
|
| 1740 |
+
unarchivedSessions: unarchived.totalCount,
|
| 1741 |
+
retainedSessions: retained.totalCount,
|
| 1742 |
+
};
|
| 1743 |
+
}
|
| 1744 |
+
// Normal inventory maintenance can archive older fixture sessions while seeding.
|
| 1745 |
+
// Active turns and observers use the newest sessions; history still spans the inventory.
|
| 1746 |
+
const turnSessionKeys = sessionKeys.slice(-options.concurrency);
|
| 1747 |
+
const turnAgentIds = sessionAgents.slice(-options.concurrency);
|
| 1748 |
+
const agentCoverage: BenchmarkRun["agentCoverage"] =
|
| 1749 |
+
options.agentCount > 1
|
| 1750 |
+
? {
|
| 1751 |
+
configuredAgentIds: agentIds,
|
| 1752 |
+
activeTurnAgentIds: [...new Set(turnAgentIds)],
|
| 1753 |
+
beforeLoad: [],
|
| 1754 |
+
completedTurns: [],
|
| 1755 |
+
}
|
| 1756 |
+
: undefined;
|
| 1757 |
+
if (agentCoverage) {
|
| 1758 |
+
for (const agentId of agentIds) {
|
| 1759 |
+
// Require published runtime facts before measuring the configured roster.
|
| 1760 |
+
const models = await rpc<ModelsListResult>("models.list", {
|
| 1761 |
+
agentId,
|
| 1762 |
+
view: "configured",
|
| 1763 |
+
refresh: false,
|
| 1764 |
+
});
|
| 1765 |
+
if (
|
| 1766 |
+
["gpt-5.6-luna", UTILITY_MODEL_ID].some(
|
| 1767 |
+
(id) =>
|
| 1768 |
+
!models.models.some((model) => model.provider === "openai" && model.id === id),
|
| 1769 |
+
)
|
| 1770 |
+
) {
|
| 1771 |
+
throw new Error(`Configured benchmark model is not published for ${agentId}`);
|
| 1772 |
+
}
|
| 1773 |
+
const storePath = path.join(
|
| 1774 |
+
root,
|
| 1775 |
+
"state",
|
| 1776 |
+
"agents",
|
| 1777 |
+
agentId,
|
| 1778 |
+
"agent",
|
| 1779 |
+
"openclaw-agent.sqlite",
|
| 1780 |
+
);
|
| 1781 |
+
const sessions: SessionsListResult[] = [];
|
| 1782 |
+
let offset = 0;
|
| 1783 |
+
for (;;) {
|
| 1784 |
+
const page = await rpc<SessionsListResult>("sessions.list", {
|
| 1785 |
+
agentId,
|
| 1786 |
+
archived: "all",
|
| 1787 |
+
limit: 1_000,
|
| 1788 |
+
offset,
|
| 1789 |
+
});
|
| 1790 |
+
if (page.path !== storePath || page.count !== page.sessions.length) {
|
| 1791 |
+
throw new Error(`Benchmark session store or page mismatch for ${agentId}`);
|
| 1792 |
+
}
|
| 1793 |
+
sessions.push(page);
|
| 1794 |
+
if (!page.hasMore) {
|
| 1795 |
+
break;
|
| 1796 |
+
}
|
| 1797 |
+
if (
|
| 1798 |
+
!Number.isSafeInteger(page.nextOffset) ||
|
| 1799 |
+
page.nextOffset == null ||
|
| 1800 |
+
page.nextOffset <= offset ||
|
| 1801 |
+
page.nextOffset > sessionCount + browserTargets.length
|
| 1802 |
+
) {
|
| 1803 |
+
throw new Error(`Benchmark session pagination did not advance for ${agentId}`);
|
| 1804 |
+
}
|
| 1805 |
+
offset = page.nextOffset;
|
| 1806 |
+
}
|
| 1807 |
+
const expectedKeys = sessionKeys.filter((_, index) => sessionAgents[index] === agentId);
|
| 1808 |
+
const observedRows = sessions.flatMap((page) =>
|
| 1809 |
+
page.sessions.filter((row) => row.key.includes(":gateway-concurrency-")),
|
| 1810 |
+
);
|
| 1811 |
+
const observedKeys = new Set(observedRows.map((row) => row.key));
|
| 1812 |
+
if (
|
| 1813 |
+
observedRows.length !== expectedKeys.length ||
|
| 1814 |
+
observedKeys.size !== expectedKeys.length ||
|
| 1815 |
+
observedRows.some((row) => row.agentId !== agentId) ||
|
| 1816 |
+
expectedKeys.some((key) => !observedKeys.has(key))
|
| 1817 |
+
) {
|
| 1818 |
+
throw new Error(
|
| 1819 |
+
`Benchmark session inventory or distinct store mismatch for ${agentId}`,
|
| 1820 |
+
);
|
| 1821 |
+
}
|
| 1822 |
+
agentCoverage.beforeLoad.push({ agentId, models, sessions });
|
| 1823 |
+
}
|
| 1824 |
+
}
|
| 1825 |
+
const messageSubscriptions: TimedProbe[] = [];
|
| 1826 |
+
for (let index = 0; index < options.subscribers; index += 1) {
|
| 1827 |
+
const subscriber = await connectGateway(port, setupDeadlineAt, protocolVersion, false);
|
| 1828 |
+
auxiliaryClients.push(subscriber);
|
| 1829 |
+
if (options.visibleObserver) {
|
| 1830 |
+
await subscriber.request("sessions.observer.visibility", { visible: true });
|
| 1831 |
+
}
|
| 1832 |
+
const subscription = await timeRpcProbe(
|
| 1833 |
+
subscriber.request,
|
| 1834 |
+
"sessions.messages.subscribe",
|
| 1835 |
+
{ key: turnSessionKeys[index % turnSessionKeys.length] },
|
| 1836 |
+
runStartedAt,
|
| 1837 |
+
);
|
| 1838 |
+
messageSubscriptions.push(subscription);
|
| 1839 |
+
if (!subscription.ok) {
|
| 1840 |
+
throw new Error(`session message subscription failed: ${subscription.error}`);
|
| 1841 |
+
}
|
| 1842 |
+
}
|
| 1843 |
+
const historyClients = await Promise.all(
|
| 1844 |
+
Array.from({ length: options.historyClients }, async () => {
|
| 1845 |
+
const historyClient = await connectGateway(port, setupDeadlineAt, protocolVersion, false);
|
| 1846 |
+
auxiliaryClients.push(historyClient);
|
| 1847 |
+
return historyClient;
|
| 1848 |
+
}),
|
| 1849 |
+
);
|
| 1850 |
+
const sessionUpdateClients = await Promise.all(
|
| 1851 |
+
Array.from(
|
| 1852 |
+
{ length: options.sessionUpdates > 0 ? options.sessionUpdateClients : 0 },
|
| 1853 |
+
async () => {
|
| 1854 |
+
const updateClient = await connectGateway(
|
| 1855 |
+
port,
|
| 1856 |
+
setupDeadlineAt,
|
| 1857 |
+
protocolVersion,
|
| 1858 |
+
false,
|
| 1859 |
+
);
|
| 1860 |
+
auxiliaryClients.push(updateClient);
|
| 1861 |
+
return updateClient;
|
| 1862 |
+
},
|
| 1863 |
+
),
|
| 1864 |
+
);
|
| 1865 |
+
const subscriptionProbeClient =
|
| 1866 |
+
options.subscribers > 0
|
| 1867 |
+
? await connectGateway(port, setupDeadlineAt, protocolVersion, false)
|
| 1868 |
+
: undefined;
|
| 1869 |
+
if (subscriptionProbeClient) {
|
| 1870 |
+
auxiliaryClients.push(subscriptionProbeClient);
|
| 1871 |
+
}
|
| 1872 |
+
const memoryBefore = await readGatewayMemory(rpc, runStartedAt);
|
| 1873 |
+
mockCheckpoints.push(await readMockRequests(mockPort, setupDeadlineAt));
|
| 1874 |
+
if (loadCpuProfilePath) {
|
| 1875 |
+
await controlGatewayProfile(gateway, "cpu", "start", loadCpuProfilePath, {
|
| 1876 |
+
includeWorkers: true,
|
| 1877 |
+
});
|
| 1878 |
+
}
|
| 1879 |
+
if (heapProfilePath) {
|
| 1880 |
+
await controlGatewayProfile(gateway, "heap", "start", heapProfilePath, {
|
| 1881 |
+
includeWorkers: true,
|
| 1882 |
+
});
|
| 1883 |
+
}
|
| 1884 |
+
const setupDurationMs = performance.now() - setupStartedAt;
|
| 1885 |
+
// Large session fixtures are setup, not benchmarked load. Every measured
|
| 1886 |
+
// run therefore gets its complete timeout after all clients are ready.
|
| 1887 |
+
const loadDeadlineAt = performance.now() + options.timeoutMs;
|
| 1888 |
+
client.setDeadlineAt(loadDeadlineAt);
|
| 1889 |
+
for (const auxiliaryClient of auxiliaryClients) {
|
| 1890 |
+
auxiliaryClient.setDeadlineAt(loadDeadlineAt);
|
| 1891 |
+
}
|
| 1892 |
+
const controlPlane: BenchmarkRun["controlPlane"] = [];
|
| 1893 |
+
const controlUi: ControlUiProbe[] = [];
|
| 1894 |
+
const history: BenchmarkRun["history"] = [];
|
| 1895 |
+
const messageSubscriptionsDuringLoad: TimedProbe[] = [];
|
| 1896 |
+
const readyz: ReadyProbe[] = [];
|
| 1897 |
+
const sessionsList: TimedProbe[] = [];
|
| 1898 |
+
const sessionUpdates: TimedProbe[] = [];
|
| 1899 |
+
let peakRssMb = memoryBefore.rssMb;
|
| 1900 |
+
let lastRssSampleAt = performance.now();
|
| 1901 |
+
let turnsDone = false;
|
| 1902 |
+
let updatesDone = options.sessionUpdates === 0;
|
| 1903 |
+
let browserDone = !browserProbe;
|
| 1904 |
+
const workloadDone = () => turnsDone && updatesDone && browserDone;
|
| 1905 |
+
let startedTurnCount = 0;
|
| 1906 |
+
let resolveAllTurnsStarted!: () => void;
|
| 1907 |
+
const allTurnsStarted = new Promise<void>((resolve) => {
|
| 1908 |
+
resolveAllTurnsStarted = resolve;
|
| 1909 |
+
});
|
| 1910 |
+
const cpuBefore = await readGatewayCpuUsage(gateway);
|
| 1911 |
+
const turnsStartedAt = performance.now();
|
| 1912 |
+
// Keep the live artifact intact: buffered setup writes can arrive after this boundary.
|
| 1913 |
+
// Inclusive millisecond timestamps conservatively include events on the boundary.
|
| 1914 |
+
const timelineFrom = Date.now();
|
| 1915 |
+
const loadStartMonotonicMicros = Number(process.hrtime.bigint() / 1_000n);
|
| 1916 |
+
const turns = Promise.all(
|
| 1917 |
+
turnSessionKeys.map((sessionKey, index) =>
|
| 1918 |
+
runSessionTurns(rpc, index, loadDeadlineAt, {
|
| 1919 |
+
onStarted: () => {
|
| 1920 |
+
startedTurnCount += 1;
|
| 1921 |
+
if (startedTurnCount === options.concurrency) {
|
| 1922 |
+
resolveAllTurnsStarted();
|
| 1923 |
+
}
|
| 1924 |
+
},
|
| 1925 |
+
sessionKey,
|
| 1926 |
+
toolEvents: options.toolEvents,
|
| 1927 |
+
turnsPerSession: options.turnsPerSession,
|
| 1928 |
+
evidence: turnEvidence,
|
| 1929 |
+
}),
|
| 1930 |
+
),
|
| 1931 |
+
).finally(() => {
|
| 1932 |
+
turnsDone = true;
|
| 1933 |
+
resolveAllTurnsStarted();
|
| 1934 |
+
});
|
| 1935 |
+
const freshConnection = allTurnsStarted.then(async (): Promise<FreshConnectionProbe> => {
|
| 1936 |
+
const startedAt = performance.now();
|
| 1937 |
+
try {
|
| 1938 |
+
const freshClient = await connectGateway(port, loadDeadlineAt, protocolVersion, false);
|
| 1939 |
+
freshClient.close();
|
| 1940 |
+
return { error: null, latencyMs: performance.now() - startedAt, ok: true };
|
| 1941 |
+
} catch (error) {
|
| 1942 |
+
return {
|
| 1943 |
+
error: describeProbeError(error),
|
| 1944 |
+
latencyMs: performance.now() - startedAt,
|
| 1945 |
+
ok: false,
|
| 1946 |
+
};
|
| 1947 |
+
}
|
| 1948 |
+
});
|
| 1949 |
+
const browserClicks = allTurnsStarted.then(async (): Promise<BrowserSessionClick[]> => {
|
| 1950 |
+
try {
|
| 1951 |
+
if (!browserProbe) {
|
| 1952 |
+
return [];
|
| 1953 |
+
}
|
| 1954 |
+
const clicks: BrowserSessionClick[] = [];
|
| 1955 |
+
const targets = [...browserTargets.slice(1), browserTargets.at(-2)!];
|
| 1956 |
+
for (const [index, target] of targets.entries()) {
|
| 1957 |
+
clicks.push(
|
| 1958 |
+
await browserProbe.click(
|
| 1959 |
+
target,
|
| 1960 |
+
index === targets.length - 1,
|
| 1961 |
+
() => !turnsDone,
|
| 1962 |
+
Math.min(30_000, requireRemainingMs(loadDeadlineAt, "clicking sidebar session")),
|
| 1963 |
+
),
|
| 1964 |
+
);
|
| 1965 |
+
}
|
| 1966 |
+
return clicks;
|
| 1967 |
+
} finally {
|
| 1968 |
+
browserDone = true;
|
| 1969 |
+
}
|
| 1970 |
+
});
|
| 1971 |
+
const sampler = runProbeRounds({
|
| 1972 |
+
rounds: options.probeRounds,
|
| 1973 |
+
deadlineAt: loadDeadlineAt,
|
| 1974 |
+
cadenceMs: options.cadenceMs,
|
| 1975 |
+
cadenceFrom: "start",
|
| 1976 |
+
runFirst: true,
|
| 1977 |
+
shouldContinue: () => !workloadDone() && readyz.length < MAX_SAMPLES_PER_RUN,
|
| 1978 |
+
stopped: () => probesStopped,
|
| 1979 |
+
runRound: async () => {
|
| 1980 |
+
const subscriptionKey = turnSessionKeys[readyz.length % turnSessionKeys.length];
|
| 1981 |
+
const [sample, subscription, controlProbes] = await Promise.all([
|
| 1982 |
+
sampleGateway({
|
| 1983 |
+
deadlineAt: loadDeadlineAt,
|
| 1984 |
+
port,
|
| 1985 |
+
rpc,
|
| 1986 |
+
runStartedAt,
|
| 1987 |
+
}),
|
| 1988 |
+
subscriptionProbeClient && subscriptionKey
|
| 1989 |
+
? timeRpcProbe(
|
| 1990 |
+
subscriptionProbeClient.request,
|
| 1991 |
+
"sessions.messages.subscribe",
|
| 1992 |
+
{ key: subscriptionKey },
|
| 1993 |
+
runStartedAt,
|
| 1994 |
+
)
|
| 1995 |
+
: Promise.resolve(undefined),
|
| 1996 |
+
options.controlPlane
|
| 1997 |
+
? Promise.all(
|
| 1998 |
+
["tasks.list", "cron.list", "cron.status"].map(async (method) =>
|
| 1999 |
+
Object.assign(await timeRpcProbe(rpc, method, {}, runStartedAt), { method }),
|
| 2000 |
+
),
|
| 2001 |
+
)
|
| 2002 |
+
: Promise.resolve([]),
|
| 2003 |
+
]);
|
| 2004 |
+
controlPlane.push(...controlProbes);
|
| 2005 |
+
if (subscription) {
|
| 2006 |
+
messageSubscriptionsDuringLoad.push(subscription);
|
| 2007 |
+
if (subscription.ok) {
|
| 2008 |
+
await subscriptionProbeClient?.request("sessions.messages.unsubscribe", {
|
| 2009 |
+
key: subscriptionKey,
|
| 2010 |
+
});
|
| 2011 |
+
}
|
| 2012 |
+
}
|
| 2013 |
+
readyz.push(sample.readyz);
|
| 2014 |
+
sessionsList.push(sample.sessionsList);
|
| 2015 |
+
controlUi.push(sample.controlUi);
|
| 2016 |
+
if (performance.now() - lastRssSampleAt >= 1_000) {
|
| 2017 |
+
// Linux reads procfs without spawning a process. Non-Linux hosts use
|
| 2018 |
+
// the shared ps fallback at most once per second to bound perturbation.
|
| 2019 |
+
peakRssMb = Math.max(peakRssMb, readGatewayProcessRssMb(gateway?.pid) ?? 0);
|
| 2020 |
+
lastRssSampleAt = performance.now();
|
| 2021 |
+
}
|
| 2022 |
+
},
|
| 2023 |
+
});
|
| 2024 |
+
probeJobs.push(sampler);
|
| 2025 |
+
const historyLoad = Promise.all(
|
| 2026 |
+
historyClients.map((historyClient, clientIndex) => {
|
| 2027 |
+
let offset = clientIndex * options.historyBurst;
|
| 2028 |
+
const job = runProbeRounds({
|
| 2029 |
+
rounds: options.probeRounds,
|
| 2030 |
+
deadlineAt: loadDeadlineAt,
|
| 2031 |
+
cadenceMs: options.cadenceMs,
|
| 2032 |
+
cadenceFrom: "completion",
|
| 2033 |
+
runFirst: false,
|
| 2034 |
+
shouldContinue: () => !workloadDone() && history.length < MAX_SAMPLES_PER_RUN,
|
| 2035 |
+
stopped: () => probesStopped,
|
| 2036 |
+
runRound: async () => {
|
| 2037 |
+
const probes = await Promise.all(
|
| 2038 |
+
Array.from({ length: options.historyBurst }, (_, index) => {
|
| 2039 |
+
const sessionKey = sessionKeys[(offset + index) % sessionKeys.length]!;
|
| 2040 |
+
const probe = timeRpcProbe(
|
| 2041 |
+
historyClient.request,
|
| 2042 |
+
"chat.history",
|
| 2043 |
+
{ sessionKey },
|
| 2044 |
+
runStartedAt,
|
| 2045 |
+
);
|
| 2046 |
+
return agentCoverage
|
| 2047 |
+
? probe.then((sample) => ({ ...sample, sessionKey }))
|
| 2048 |
+
: probe;
|
| 2049 |
+
}),
|
| 2050 |
+
);
|
| 2051 |
+
history.push(...probes);
|
| 2052 |
+
offset += options.historyBurst;
|
| 2053 |
+
},
|
| 2054 |
+
});
|
| 2055 |
+
probeJobs.push(job);
|
| 2056 |
+
return job;
|
| 2057 |
+
}),
|
| 2058 |
+
);
|
| 2059 |
+
let nextUpdateIndex = 0;
|
| 2060 |
+
const sessionUpdateLoad = Promise.all(
|
| 2061 |
+
sessionUpdateClients.map(async (updateClient) => {
|
| 2062 |
+
for (;;) {
|
| 2063 |
+
const index = nextUpdateIndex++;
|
| 2064 |
+
if (index >= options.sessionUpdates) {
|
| 2065 |
+
return;
|
| 2066 |
+
}
|
| 2067 |
+
const sessionKey = sessionKeys[index % sessionKeys.length];
|
| 2068 |
+
const update = await timeRpcProbe(
|
| 2069 |
+
updateClient.request,
|
| 2070 |
+
"sessions.patch",
|
| 2071 |
+
{ key: sessionKey, label: `Benchmark update ${index + 1}` },
|
| 2072 |
+
runStartedAt,
|
| 2073 |
+
);
|
| 2074 |
+
sessionUpdates.push(update);
|
| 2075 |
+
if (!update.ok) {
|
| 2076 |
+
throw new Error(`sessions.patch load probe failed: ${update.error}`);
|
| 2077 |
+
}
|
| 2078 |
+
}
|
| 2079 |
+
}),
|
| 2080 |
+
).finally(() => {
|
| 2081 |
+
updatesDone = true;
|
| 2082 |
+
});
|
| 2083 |
+
const [freshConnectionResult, sessionTurnCounts, browserClickResults] = await Promise.all([
|
| 2084 |
+
freshConnection,
|
| 2085 |
+
turns,
|
| 2086 |
+
browserClicks,
|
| 2087 |
+
sampler,
|
| 2088 |
+
historyLoad,
|
| 2089 |
+
sessionUpdateLoad,
|
| 2090 |
+
]);
|
| 2091 |
+
const cpuAfter = await readGatewayCpuUsage(gateway);
|
| 2092 |
+
const loadEndMonotonicMicros = Number(process.hrtime.bigint() / 1_000n);
|
| 2093 |
+
const turnsDurationMs = performance.now() - turnsStartedAt;
|
| 2094 |
+
const memoryAfter = await readGatewayMemory(rpc, runStartedAt);
|
| 2095 |
+
timelineWindow = { from: timelineFrom, through: Date.now() };
|
| 2096 |
+
let loadCpuProfile: BenchmarkRun["loadCpuProfile"];
|
| 2097 |
+
if (loadCpuProfilePath) {
|
| 2098 |
+
await controlGatewayProfile(gateway, "cpu", "stop", loadCpuProfilePath);
|
| 2099 |
+
loadCpuProfile = {
|
| 2100 |
+
...readGatewayCpuProfile(loadCpuProfilePath),
|
| 2101 |
+
scope: "main-isolate",
|
| 2102 |
+
workersManifestPath: `${loadCpuProfilePath}.workers.json`,
|
| 2103 |
+
};
|
| 2104 |
+
}
|
| 2105 |
+
let heapProfile: BenchmarkRun["heapProfile"];
|
| 2106 |
+
if (heapProfilePath) {
|
| 2107 |
+
await controlGatewayProfile(gateway, "heap", "stop", heapProfilePath);
|
| 2108 |
+
heapProfile = {
|
| 2109 |
+
...readGatewayHeapProfile(heapProfilePath),
|
| 2110 |
+
scope: "main-isolate",
|
| 2111 |
+
workersManifestPath: `${heapProfilePath}.workers.json`,
|
| 2112 |
+
};
|
| 2113 |
+
}
|
| 2114 |
+
if (options.historyClients > 0 && !history.some((sample) => sample.ok)) {
|
| 2115 |
+
const failure = history[0]?.error ?? "no requests completed before turns finished";
|
| 2116 |
+
throw new Error(`all configured chat.history load probes failed: ${failure}`);
|
| 2117 |
+
}
|
| 2118 |
+
peakRssMb = Math.max(peakRssMb, memoryAfter.rssMb);
|
| 2119 |
+
mockCheckpoints.push(await readMockRequests(mockPort, loadDeadlineAt));
|
| 2120 |
+
|
| 2121 |
+
if (agentCoverage) {
|
| 2122 |
+
agentCoverage.completedTurns = agentIds.map((agentId) => ({
|
| 2123 |
+
agentId,
|
| 2124 |
+
count: sessionTurnCounts.reduce(
|
| 2125 |
+
(total, count, index) => total + (turnAgentIds[index] === agentId ? count : 0),
|
| 2126 |
+
0,
|
| 2127 |
+
),
|
| 2128 |
+
}));
|
| 2129 |
+
}
|
| 2130 |
+
result = {
|
| 2131 |
+
...(agentCoverage ? { agentCoverage } : {}),
|
| 2132 |
+
...(browserProbe && browserInventory
|
| 2133 |
+
? {
|
| 2134 |
+
browser: {
|
| 2135 |
+
newPageReadyMs: browserProbe.newPageReadyMs,
|
| 2136 |
+
initialSessionReadyMs: browserProbe.initialSessionReadyMs,
|
| 2137 |
+
historyMessagesPerTarget: browserHistoryMessages,
|
| 2138 |
+
inventory: browserInventory,
|
| 2139 |
+
clicks: browserClickResults,
|
| 2140 |
+
},
|
| 2141 |
+
}
|
| 2142 |
+
: {}),
|
| 2143 |
+
...(heapProfile ? { heapProfile } : {}),
|
| 2144 |
+
...(loadCpuProfile ? { loadCpuProfile } : {}),
|
| 2145 |
+
controlPlane,
|
| 2146 |
+
controlUi,
|
| 2147 |
+
cpuUsage: measureGatewayCpuUsage(cpuBefore, cpuAfter),
|
| 2148 |
+
durationMs: performance.now() - runStartedAt,
|
| 2149 |
+
freshConnection: freshConnectionResult,
|
| 2150 |
+
history,
|
| 2151 |
+
loadWindow: {
|
| 2152 |
+
startMonotonicMicros: loadStartMonotonicMicros,
|
| 2153 |
+
endMonotonicMicros: loadEndMonotonicMicros,
|
| 2154 |
+
},
|
| 2155 |
+
memory: { after: memoryAfter, before: memoryBefore, peakRssMb },
|
| 2156 |
+
messageSubscriptions,
|
| 2157 |
+
messageSubscriptionsDuringLoad,
|
| 2158 |
+
probeWarmup,
|
| 2159 |
+
pluginMetadataScans: summarizePluginMetadataScans([]),
|
| 2160 |
+
readyz,
|
| 2161 |
+
sessionSeedDurationMs,
|
| 2162 |
+
sessionsList,
|
| 2163 |
+
sessionUpdates,
|
| 2164 |
+
setupDurationMs,
|
| 2165 |
+
turnCount: sessionTurnCounts.reduce((sum, count) => sum + count, 0),
|
| 2166 |
+
turnsDurationMs,
|
| 2167 |
+
};
|
| 2168 |
+
} catch (error) {
|
| 2169 |
+
const detail = formatRunFailure(error, gatewayOutput, mockOutput);
|
| 2170 |
+
throw new Error(detail, { cause: error });
|
| 2171 |
+
} finally {
|
| 2172 |
+
probesStopped = true;
|
| 2173 |
+
try {
|
| 2174 |
+
await browserProbe?.close();
|
| 2175 |
+
} finally {
|
| 2176 |
+
for (const auxiliaryClient of auxiliaryClients) {
|
| 2177 |
+
auxiliaryClient.close();
|
| 2178 |
+
}
|
| 2179 |
+
try {
|
| 2180 |
+
if (gateway) {
|
| 2181 |
+
if (options.cpuProfDir && gateway.exitCode === null && gateway.signalCode === null) {
|
| 2182 |
+
// V8 flushes the main-isolate CPU profile on its normal interrupt path.
|
| 2183 |
+
const profileFlushed = new Promise<void>((resolve) => {
|
| 2184 |
+
gateway!.once("exit", () => {
|
| 2185 |
+
resolve();
|
| 2186 |
+
});
|
| 2187 |
+
});
|
| 2188 |
+
gateway.kill("SIGINT");
|
| 2189 |
+
await Promise.race([profileFlushed, delay(2_000)]);
|
| 2190 |
+
}
|
| 2191 |
+
gatewayExit = await stopChild(gateway);
|
| 2192 |
+
}
|
| 2193 |
+
// A fatal turn may end Promise.all before fixed probe rounds settle.
|
| 2194 |
+
// Join their closed-client failures before removing the fixture state.
|
| 2195 |
+
await Promise.allSettled(probeJobs);
|
| 2196 |
+
} finally {
|
| 2197 |
+
// Gateway shutdown drains admitted event dispatches before closing sockets.
|
| 2198 |
+
// Keep this client alive through that drain so delayed tool evidence survives.
|
| 2199 |
+
client?.close();
|
| 2200 |
+
}
|
| 2201 |
+
}
|
| 2202 |
+
}
|
| 2203 |
+
// close() initiates a handshake; retain late event evidence until it settles.
|
| 2204 |
+
await client?.waitClosed();
|
| 2205 |
+
mockCheckpoints.push(await readMockRequests(mockPort, performance.now() + HTTP_TIMEOUT_MS));
|
| 2206 |
+
if (options.diagnosticsTimeline) {
|
| 2207 |
+
if (!gatewayExit || gatewayExit.exitCode !== 0 || gatewayExit.signal !== null) {
|
| 2208 |
+
throw new Error(
|
| 2209 |
+
formatRunFailure(
|
| 2210 |
+
new Error(
|
| 2211 |
+
`Gateway did not exit cleanly; diagnostics timeline may be incomplete: ${JSON.stringify({ helper: gatewayExit, child: readGatewayProcess() })}`,
|
| 2212 |
+
),
|
| 2213 |
+
gatewayOutput,
|
| 2214 |
+
mockOutput,
|
| 2215 |
+
),
|
| 2216 |
+
);
|
| 2217 |
+
}
|
| 2218 |
+
if (gatewayOutput.readOutput().includes("[diagnostics] failed to write timeline event")) {
|
| 2219 |
+
throw new Error("Gateway reported a diagnostics timeline write failure");
|
| 2220 |
+
}
|
| 2221 |
+
result.pluginMetadataScans = summarizePluginMetadataScans(
|
| 2222 |
+
readDiagnosticsTimelineSpans(timelinePath, timelineWindow),
|
| 2223 |
+
);
|
| 2224 |
+
}
|
| 2225 |
+
return {
|
| 2226 |
+
...result,
|
| 2227 |
+
mockRequests: summarizeMockRequests(mockCheckpoints),
|
| 2228 |
+
turnEvidence: turnEvidence.finish(),
|
| 2229 |
+
gatewayProcess: readGatewayProcess(),
|
| 2230 |
+
...(gatewayExit ? { gatewayExit } : {}),
|
| 2231 |
+
};
|
| 2232 |
+
} finally {
|
| 2233 |
+
try {
|
| 2234 |
+
if (mockProvider) {
|
| 2235 |
+
await stopChild(mockProvider);
|
| 2236 |
+
}
|
| 2237 |
+
} finally {
|
| 2238 |
+
rmSync(root, { force: true, maxRetries: 3, recursive: true, retryDelay: 100 });
|
| 2239 |
+
}
|
| 2240 |
+
}
|
| 2241 |
+
}
|
| 2242 |
+
|
| 2243 |
+
function summarizeRuns(
|
| 2244 |
+
runs: readonly BenchmarkRun[],
|
| 2245 |
+
options: Pick<CliOptions, "maxControlMs" | "maxHandshakeMs"> = {},
|
| 2246 |
+
) {
|
| 2247 |
+
const controlPlane = runs.flatMap((run) => run.controlPlane);
|
| 2248 |
+
const browserClicks = runs.flatMap((run) => run.browser?.clicks ?? []);
|
| 2249 |
+
const controlUi = runs.flatMap((run) => run.controlUi);
|
| 2250 |
+
const readyz = runs.flatMap((run) => run.readyz);
|
| 2251 |
+
const sessionsList = runs.flatMap((run) => run.sessionsList);
|
| 2252 |
+
const history = runs.flatMap((run) => run.history);
|
| 2253 |
+
const subscriptions = runs.flatMap((run) => run.messageSubscriptions);
|
| 2254 |
+
const subscriptionsDuringLoad = runs.flatMap((run) => run.messageSubscriptionsDuringLoad);
|
| 2255 |
+
const sessionUpdates = runs.flatMap((run) => run.sessionUpdates);
|
| 2256 |
+
// Setup subscriptions and warmup probes are not load-phase measurements.
|
| 2257 |
+
const controlMethods = ["tasks.list", "cron.list", "cron.status"];
|
| 2258 |
+
const controlMethodProbes = controlMethods.map((method) => ({
|
| 2259 |
+
method,
|
| 2260 |
+
samples: controlPlane.filter((sample) => sample.method === method),
|
| 2261 |
+
}));
|
| 2262 |
+
const budgetViolations = [
|
| 2263 |
+
...controlMethodProbes.map(({ method, samples }) => ({
|
| 2264 |
+
name: `Gateway ${method} probe`,
|
| 2265 |
+
maxMs: options.maxControlMs,
|
| 2266 |
+
samples,
|
| 2267 |
+
})),
|
| 2268 |
+
{
|
| 2269 |
+
name: "fresh Gateway connection",
|
| 2270 |
+
maxMs: options.maxHandshakeMs,
|
| 2271 |
+
samples: runs.map((run) => run.freshConnection),
|
| 2272 |
+
},
|
| 2273 |
+
...(
|
| 2274 |
+
[
|
| 2275 |
+
["readyz", readyz],
|
| 2276 |
+
["Control UI", controlUi],
|
| 2277 |
+
["sessions.list", sessionsList],
|
| 2278 |
+
["chat.history", history],
|
| 2279 |
+
["sessions.messages.subscribe", subscriptionsDuringLoad],
|
| 2280 |
+
["sessions.patch", sessionUpdates],
|
| 2281 |
+
] as const
|
| 2282 |
+
).map(([name, samples]) => ({
|
| 2283 |
+
name: `Gateway ${name} probe`,
|
| 2284 |
+
maxMs: options.maxControlMs,
|
| 2285 |
+
samples,
|
| 2286 |
+
})),
|
| 2287 |
+
].flatMap(({ name, maxMs, samples }) => {
|
| 2288 |
+
if (maxMs === undefined) {
|
| 2289 |
+
return [];
|
| 2290 |
+
}
|
| 2291 |
+
const violation = samples.find((sample) => !sample.ok || sample.latencyMs > maxMs);
|
| 2292 |
+
return violation
|
| 2293 |
+
? [
|
| 2294 |
+
`${name} exceeded ${maxMs}ms: ok=${violation.ok} ` +
|
| 2295 |
+
`latencyMs=${violation.latencyMs.toFixed(1)} error=${violation.error ?? "none"}`,
|
| 2296 |
+
]
|
| 2297 |
+
: [];
|
| 2298 |
+
});
|
| 2299 |
+
for (const sample of browserClicks) {
|
| 2300 |
+
if (sample.error) {
|
| 2301 |
+
budgetViolations.push(`Browser session ${sample.sessionKey} failed: ${sample.error}`);
|
| 2302 |
+
}
|
| 2303 |
+
}
|
| 2304 |
+
return {
|
| 2305 |
+
...(browserClicks.length > 0
|
| 2306 |
+
? {
|
| 2307 |
+
browserSessionClicks: {
|
| 2308 |
+
failedSamples: browserClicks.filter((sample) => sample.error !== null).length,
|
| 2309 |
+
samplesOutsideActiveLoad: browserClicks.filter(
|
| 2310 |
+
(sample) => !sample.activeLoadAtStart || !sample.activeLoadAtFinish,
|
| 2311 |
+
).length,
|
| 2312 |
+
firstVisitReadyMs: summarizeNumbers(
|
| 2313 |
+
browserClicks.flatMap((sample) =>
|
| 2314 |
+
!sample.revisit && sample.readyMs !== null ? [sample.readyMs] : [],
|
| 2315 |
+
),
|
| 2316 |
+
),
|
| 2317 |
+
revisitReadyMs: summarizeNumbers(
|
| 2318 |
+
browserClicks.flatMap((sample) =>
|
| 2319 |
+
sample.revisit && sample.readyMs !== null ? [sample.readyMs] : [],
|
| 2320 |
+
),
|
| 2321 |
+
),
|
| 2322 |
+
},
|
| 2323 |
+
}
|
| 2324 |
+
: {}),
|
| 2325 |
+
budgetViolations,
|
| 2326 |
+
gatewayProcessCpuMs: summarizeNumbers(runs.map((run) => run.cpuUsage.process.totalMs)),
|
| 2327 |
+
gatewayProcessCpuMsPerTurn: summarizeNumbers(
|
| 2328 |
+
runs.map((run) => run.cpuUsage.process.totalMs / run.turnCount),
|
| 2329 |
+
),
|
| 2330 |
+
gatewayMainThreadCpuMs: summarizeNumbers(runs.map((run) => run.cpuUsage.mainThread.totalMs)),
|
| 2331 |
+
gatewayProcessCpuCoreRatio: summarizeNumbers(
|
| 2332 |
+
runs.map((run) => run.cpuUsage.process.totalMs / run.cpuUsage.wallMs),
|
| 2333 |
+
),
|
| 2334 |
+
gatewaySampledAllocatedBytes: summarizeNumbers(
|
| 2335 |
+
runs.flatMap((run) => (run.heapProfile ? [run.heapProfile.sampledAllocatedBytes] : [])),
|
| 2336 |
+
),
|
| 2337 |
+
gatewaySampledAllocatedBytesPerTurn: summarizeNumbers(
|
| 2338 |
+
runs.flatMap((run) =>
|
| 2339 |
+
run.heapProfile ? [run.heapProfile.sampledAllocatedBytes / run.turnCount] : [],
|
| 2340 |
+
),
|
| 2341 |
+
),
|
| 2342 |
+
controlPlane: Object.fromEntries(
|
| 2343 |
+
controlMethodProbes.map(({ method, samples }) => [
|
| 2344 |
+
method,
|
| 2345 |
+
{
|
| 2346 |
+
failedSamples: samples.filter((sample) => !sample.ok).length,
|
| 2347 |
+
latencyMs: summarizeNumbers(samples.map((sample) => sample.latencyMs)),
|
| 2348 |
+
},
|
| 2349 |
+
]),
|
| 2350 |
+
),
|
| 2351 |
+
controlUiFailedSamples: controlUi.filter((sample) => !sample.ok).length,
|
| 2352 |
+
controlUiLatencyMs: summarizeNumbers(controlUi.map((sample) => sample.latencyMs)),
|
| 2353 |
+
cpuCoreRatio: summarizeNumbers(
|
| 2354 |
+
readyz.flatMap((sample) => (sample.cpuCoreRatio == null ? [] : [sample.cpuCoreRatio])),
|
| 2355 |
+
),
|
| 2356 |
+
degradedSamples: readyz.filter((sample) => sample.degraded === true).length,
|
| 2357 |
+
eventLoopDelayMaxMs: summarizeNumbers(
|
| 2358 |
+
readyz.flatMap((sample) => (sample.delayMaxMs == null ? [] : [sample.delayMaxMs])),
|
| 2359 |
+
),
|
| 2360 |
+
eventLoopDelayP99Ms: summarizeNumbers(
|
| 2361 |
+
readyz.flatMap((sample) => (sample.delayP99Ms == null ? [] : [sample.delayP99Ms])),
|
| 2362 |
+
),
|
| 2363 |
+
eventLoopUtilization: summarizeNumbers(
|
| 2364 |
+
readyz.flatMap((sample) => (sample.utilization == null ? [] : [sample.utilization])),
|
| 2365 |
+
),
|
| 2366 |
+
freshConnectionFailedRuns: runs.filter((run) => !run.freshConnection.ok).length,
|
| 2367 |
+
freshConnectionLatencyMs: summarizeNumbers(runs.map((run) => run.freshConnection.latencyMs)),
|
| 2368 |
+
gatewayUncleanExits: runs.filter(
|
| 2369 |
+
(run) =>
|
| 2370 |
+
run.gatewayExit && (run.gatewayExit.exitCode !== 0 || run.gatewayExit.signal !== null),
|
| 2371 |
+
).length,
|
| 2372 |
+
gatewayExternalGrowthMb: summarizeNumbers(
|
| 2373 |
+
runs.flatMap((run) => {
|
| 2374 |
+
const before = run.memory.before.externalMb;
|
| 2375 |
+
const after = run.memory.after.externalMb;
|
| 2376 |
+
return before === undefined || after === undefined ? [] : [after - before];
|
| 2377 |
+
}),
|
| 2378 |
+
),
|
| 2379 |
+
gatewayExternalMb: summarizeNumbers(
|
| 2380 |
+
runs.flatMap((run) =>
|
| 2381 |
+
run.memory.after.externalMb === undefined ? [] : [run.memory.after.externalMb],
|
| 2382 |
+
),
|
| 2383 |
+
),
|
| 2384 |
+
gatewayArrayBuffersGrowthMb: summarizeNumbers(
|
| 2385 |
+
runs.flatMap((run) => {
|
| 2386 |
+
const before = run.memory.before.arrayBuffersMb;
|
| 2387 |
+
const after = run.memory.after.arrayBuffersMb;
|
| 2388 |
+
return before === undefined || after === undefined ? [] : [after - before];
|
| 2389 |
+
}),
|
| 2390 |
+
),
|
| 2391 |
+
gatewayArrayBuffersMb: summarizeNumbers(
|
| 2392 |
+
runs.flatMap((run) =>
|
| 2393 |
+
run.memory.after.arrayBuffersMb === undefined ? [] : [run.memory.after.arrayBuffersMb],
|
| 2394 |
+
),
|
| 2395 |
+
),
|
| 2396 |
+
gatewayHeapGrowthMb: summarizeNumbers(
|
| 2397 |
+
runs.map((run) => run.memory.after.heapUsedMb - run.memory.before.heapUsedMb),
|
| 2398 |
+
),
|
| 2399 |
+
gatewayHeapUsedMb: summarizeNumbers(runs.map((run) => run.memory.after.heapUsedMb)),
|
| 2400 |
+
gatewayPeakRssMb: summarizeNumbers(runs.map((run) => run.memory.peakRssMb)),
|
| 2401 |
+
gatewayRssGrowthMb: summarizeNumbers(
|
| 2402 |
+
runs.map((run) => run.memory.after.rssMb - run.memory.before.rssMb),
|
| 2403 |
+
),
|
| 2404 |
+
historyFailedSamples: history.filter((sample) => !sample.ok).length,
|
| 2405 |
+
historyLatencyMs: summarizeNumbers(history.map((sample) => sample.latencyMs)),
|
| 2406 |
+
historySampleCount: history.length,
|
| 2407 |
+
messageSubscriptionFailedSamples: subscriptions.filter((sample) => !sample.ok).length,
|
| 2408 |
+
messageSubscriptionLatencyMs: summarizeNumbers(subscriptions.map((sample) => sample.latencyMs)),
|
| 2409 |
+
messageSubscriptionLoadFailedSamples: subscriptionsDuringLoad.filter((sample) => !sample.ok)
|
| 2410 |
+
.length,
|
| 2411 |
+
messageSubscriptionLoadLatencyMs: summarizeNumbers(
|
| 2412 |
+
subscriptionsDuringLoad.map((sample) => sample.latencyMs),
|
| 2413 |
+
),
|
| 2414 |
+
mockRequestIngress: Object.fromEntries(
|
| 2415 |
+
MOCK_INGRESS_KEYS.map((key) => [
|
| 2416 |
+
key,
|
| 2417 |
+
runs.reduce((count, run) => count + run.mockRequests.ingress.total[key], 0),
|
| 2418 |
+
]),
|
| 2419 |
+
),
|
| 2420 |
+
mockResponseSelections: Object.fromEntries(
|
| 2421 |
+
MOCK_SELECTION_KEYS.map((key) => [
|
| 2422 |
+
key,
|
| 2423 |
+
runs.reduce((count, run) => count + run.mockRequests.selections[key], 0),
|
| 2424 |
+
]),
|
| 2425 |
+
),
|
| 2426 |
+
toolTurns: runs.reduce((count, run) => count + run.turnEvidence.toolTurns, 0),
|
| 2427 |
+
observerModelDigestTurns: runs.reduce(
|
| 2428 |
+
(count, run) => count + run.turnEvidence.observerModelDigestTurns,
|
| 2429 |
+
0,
|
| 2430 |
+
),
|
| 2431 |
+
pluginMetadataScanCount: runs.reduce((sum, run) => sum + run.pluginMetadataScans.count, 0),
|
| 2432 |
+
pluginMetadataScanTotalDurationMs: runs.reduce(
|
| 2433 |
+
(sum, run) => sum + run.pluginMetadataScans.totalDurationMs,
|
| 2434 |
+
0,
|
| 2435 |
+
),
|
| 2436 |
+
readyzLatencyMs: summarizeNumbers(readyz.map((sample) => sample.latencyMs)),
|
| 2437 |
+
readyzFailedSamples: readyz.filter((sample) => !sample.ok).length,
|
| 2438 |
+
sampleCount: readyz.length,
|
| 2439 |
+
sessionSeedDurationMs: summarizeNumbers(runs.map((run) => run.sessionSeedDurationMs)),
|
| 2440 |
+
sessionsListLatencyMs: summarizeNumbers(sessionsList.map((sample) => sample.latencyMs)),
|
| 2441 |
+
sessionsListFailedSamples: sessionsList.filter((sample) => !sample.ok).length,
|
| 2442 |
+
sessionUpdateFailedSamples: sessionUpdates.filter((sample) => !sample.ok).length,
|
| 2443 |
+
sessionUpdateLatencyMs: summarizeNumbers(sessionUpdates.map((sample) => sample.latencyMs)),
|
| 2444 |
+
sessionUpdateSampleCount: sessionUpdates.length,
|
| 2445 |
+
setupDurationMs: summarizeNumbers(runs.map((run) => run.setupDurationMs)),
|
| 2446 |
+
turnCount: runs.reduce((sum, run) => sum + run.turnCount, 0),
|
| 2447 |
+
turnsDurationMs: summarizeNumbers(runs.map((run) => run.turnsDurationMs)),
|
| 2448 |
+
};
|
| 2449 |
+
}
|
| 2450 |
+
|
| 2451 |
+
async function runBenchmarkSamples(params: {
|
| 2452 |
+
now?: () => number;
|
| 2453 |
+
onProgress?: (message: string) => void;
|
| 2454 |
+
options: CliOptions;
|
| 2455 |
+
runSample?: typeof runGatewaySample;
|
| 2456 |
+
}): Promise<BenchmarkRun[]> {
|
| 2457 |
+
const now = params.now ?? performance.now.bind(performance);
|
| 2458 |
+
const runSample = params.runSample ?? runGatewaySample;
|
| 2459 |
+
const runs: BenchmarkRun[] = [];
|
| 2460 |
+
const total = params.options.runs + params.options.warmup;
|
| 2461 |
+
for (let index = 0; index < total; index += 1) {
|
| 2462 |
+
// Each sample gets the same budget so earlier runs cannot shrink later agent waits.
|
| 2463 |
+
// runGatewaySample extends this deadline by its probe warmup before load starts.
|
| 2464 |
+
const deadlineAt = now() + params.options.timeoutMs;
|
| 2465 |
+
const run = await runSample({ ...params.options, deadlineAt });
|
| 2466 |
+
if (index >= params.options.warmup) {
|
| 2467 |
+
runs.push(run);
|
| 2468 |
+
params.onProgress?.(
|
| 2469 |
+
`[bench-gateway-concurrency] run ${runs.length}/${params.options.runs}: turns=${run.turnCount} samples=${run.readyz.length} duration=${run.durationMs.toFixed(1)}ms`,
|
| 2470 |
+
);
|
| 2471 |
+
} else {
|
| 2472 |
+
params.onProgress?.(
|
| 2473 |
+
`[bench-gateway-concurrency] warmup ${index + 1}/${params.options.warmup}: duration=${run.durationMs.toFixed(1)}ms`,
|
| 2474 |
+
);
|
| 2475 |
+
}
|
| 2476 |
+
}
|
| 2477 |
+
return runs;
|
| 2478 |
+
}
|
| 2479 |
+
|
| 2480 |
+
async function main(): Promise<void> {
|
| 2481 |
+
const argv = process.argv.slice(2);
|
| 2482 |
+
if (hasHelpFlag(argv)) {
|
| 2483 |
+
printUsage();
|
| 2484 |
+
return;
|
| 2485 |
+
}
|
| 2486 |
+
const options = parseOptions(argv);
|
| 2487 |
+
const runs = await runBenchmarkSamples({ onProgress: console.error, options });
|
| 2488 |
+
const payload = {
|
| 2489 |
+
agentCount: options.agentCount,
|
| 2490 |
+
browserHistoryMessages: options.browserHistoryMessages,
|
| 2491 |
+
browserSessionClicks: options.browserSessionClicks,
|
| 2492 |
+
cadenceMs: options.cadenceMs,
|
| 2493 |
+
concurrency: options.concurrency,
|
| 2494 |
+
controlPlane: options.controlPlane,
|
| 2495 |
+
historyMessages: options.historyMessages,
|
| 2496 |
+
historyMessageChars: options.historyMessageChars,
|
| 2497 |
+
diagnosticsTimeline: options.diagnosticsTimeline,
|
| 2498 |
+
entry: options.entry,
|
| 2499 |
+
generatedAt: new Date().toISOString(),
|
| 2500 |
+
historyBurst: options.historyBurst,
|
| 2501 |
+
historyClients: options.historyClients,
|
| 2502 |
+
mode: "mock-streaming-agent",
|
| 2503 |
+
pluginCount: options.pluginCount,
|
| 2504 |
+
probeWorkload: {
|
| 2505 |
+
mode: options.probeRounds === undefined ? "adaptive" : "fixed-rounds",
|
| 2506 |
+
samplerRoundsPerRun: options.probeRounds ?? null,
|
| 2507 |
+
historyRequestsPerRun:
|
| 2508 |
+
options.probeRounds === undefined
|
| 2509 |
+
? null
|
| 2510 |
+
: options.probeRounds * options.historyClients * options.historyBurst,
|
| 2511 |
+
peakRssSampling: "sampler-rounds-and-final-memory",
|
| 2512 |
+
},
|
| 2513 |
+
runs,
|
| 2514 |
+
sessionCount: Math.max(options.sessionCount, options.concurrency),
|
| 2515 |
+
sessionUpdateClients: options.sessionUpdates > 0 ? options.sessionUpdateClients : 0,
|
| 2516 |
+
sessionUpdates: options.sessionUpdates,
|
| 2517 |
+
streamChunkDelayMs: options.streamChunkDelayMs,
|
| 2518 |
+
subscribers: options.subscribers,
|
| 2519 |
+
summary: summarizeRuns(runs, options),
|
| 2520 |
+
toolEvents: options.toolEvents,
|
| 2521 |
+
turnsPerSession: options.turnsPerSession,
|
| 2522 |
+
visibleObserver: options.visibleObserver,
|
| 2523 |
+
workspaceFanout: options.workspaceFanout,
|
| 2524 |
+
};
|
| 2525 |
+
if (options.output) {
|
| 2526 |
+
mkdirSync(path.dirname(options.output), { recursive: true });
|
| 2527 |
+
writeFileSync(options.output, `${JSON.stringify(payload, null, 2)}\n`);
|
| 2528 |
+
}
|
| 2529 |
+
if (options.json || !options.output) {
|
| 2530 |
+
console.log(JSON.stringify(payload, null, 2));
|
| 2531 |
+
}
|
| 2532 |
+
if (payload.summary.budgetViolations.length > 0) {
|
| 2533 |
+
throw new Error(payload.summary.budgetViolations.join("\n"));
|
| 2534 |
+
}
|
| 2535 |
+
}
|
| 2536 |
+
|
| 2537 |
+
export const testing = {
|
| 2538 |
+
parseOptions,
|
| 2539 |
+
parseMockRequests,
|
| 2540 |
+
summarizeMockRequests,
|
| 2541 |
+
createTurnEvidence,
|
| 2542 |
+
formatProbeFailure,
|
| 2543 |
+
formatRunFailure,
|
| 2544 |
+
requestHttp,
|
| 2545 |
+
runBenchmarkSamples,
|
| 2546 |
+
runProbeRounds,
|
| 2547 |
+
runSessionTurns,
|
| 2548 |
+
runTurn,
|
| 2549 |
+
sampleGateway,
|
| 2550 |
+
readDiagnosticsTimelineSpans,
|
| 2551 |
+
summarizePluginMetadataScans,
|
| 2552 |
+
summarizeNumbers,
|
| 2553 |
+
summarizeRuns,
|
| 2554 |
+
tailLines,
|
| 2555 |
+
warmGatewayProbes,
|
| 2556 |
+
};
|
| 2557 |
+
|
| 2558 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 2559 |
+
void main()
|
| 2560 |
+
.catch((error: unknown) => {
|
| 2561 |
+
console.error(error instanceof CliArgumentError ? error.message : (error as Error)?.stack);
|
| 2562 |
+
process.exitCode = 1;
|
| 2563 |
+
})
|
| 2564 |
+
.finally(() => {
|
| 2565 |
+
if (process.exitCode && process.exitCode !== 0) {
|
| 2566 |
+
console.error(`[bench-gateway-concurrency] FAILED (exit ${process.exitCode})`);
|
| 2567 |
+
}
|
| 2568 |
+
});
|
| 2569 |
+
}
|
scripts/bench-gateway-startup.ts
ADDED
|
@@ -0,0 +1,1119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Bench Gateway Startup script supports OpenClaw repository automation.
|
| 2 |
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
| 3 |
+
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
| 4 |
+
import { tmpdir } from "node:os";
|
| 5 |
+
import path from "node:path";
|
| 6 |
+
import { performance } from "node:perf_hooks";
|
| 7 |
+
import { pathToFileURL } from "node:url";
|
| 8 |
+
import { listBundledPluginPackArtifacts } from "./lib/bundled-plugin-build-entries.mjs";
|
| 9 |
+
import { delay, stopChild } from "./lib/gateway-bench-child.ts";
|
| 10 |
+
import { getFreePort, readProcessRssMb, readProcessTreeCpuMs } from "./lib/gateway-bench-probes.ts";
|
| 11 |
+
import {
|
| 12 |
+
BASE_GATEWAY_BENCH_CONFIG,
|
| 13 |
+
buildGatewayBenchChildArgs,
|
| 14 |
+
classifyGatewayReadyLog,
|
| 15 |
+
CliArgumentError,
|
| 16 |
+
collectOutputLines,
|
| 17 |
+
collectTraceLine,
|
| 18 |
+
createGatewayBenchEnv,
|
| 19 |
+
flushOutputLineBuffers,
|
| 20 |
+
formatMb,
|
| 21 |
+
formatMs,
|
| 22 |
+
formatStats,
|
| 23 |
+
hasFlag,
|
| 24 |
+
hasHelpFlag,
|
| 25 |
+
parseFlagValue,
|
| 26 |
+
parseNonNegativeInt,
|
| 27 |
+
parsePositiveInt,
|
| 28 |
+
parseRepeatableFlag,
|
| 29 |
+
resolveCases as resolveGatewayBenchCases,
|
| 30 |
+
resolveEntry as resolveGatewayBenchEntry,
|
| 31 |
+
resolveOutputPath,
|
| 32 |
+
STALLED_CATALOG_MODEL_ID,
|
| 33 |
+
STALLED_CATALOG_PROVIDER_ID,
|
| 34 |
+
summarizeNumbers,
|
| 35 |
+
summarizeTraceStats,
|
| 36 |
+
type InitialProbeResult,
|
| 37 |
+
type SummaryStats,
|
| 38 |
+
validateCliArgs as validateGatewayBenchCliArgs,
|
| 39 |
+
writeGatewayBenchConfig,
|
| 40 |
+
writePluginFixtures,
|
| 41 |
+
waitForInitialProbe as waitForProbe,
|
| 42 |
+
} from "./lib/gateway-bench-runtime.ts";
|
| 43 |
+
import { selectSlowStartupTraceDurations } from "./lib/gateway-startup-trace-ranking.js";
|
| 44 |
+
|
| 45 |
+
type GatewayBenchCase = {
|
| 46 |
+
agentTopology?: "incident-scale" | "single" | "shared-eleven-plus-distinct-one";
|
| 47 |
+
completionTracePhase?: string;
|
| 48 |
+
config: Record<string, unknown>;
|
| 49 |
+
env?: Record<string, string>;
|
| 50 |
+
id: string;
|
| 51 |
+
incidentFixture?: "combined" | "database" | "null-metadata" | "packaged-plugins" | "workspace";
|
| 52 |
+
name: string;
|
| 53 |
+
pluginActivationOnStartup?: boolean;
|
| 54 |
+
pluginCount?: number;
|
| 55 |
+
providerCatalogStallMs?: number;
|
| 56 |
+
providerStaticCatalogModelCount?: number;
|
| 57 |
+
providerStaticCatalogStallMs?: number;
|
| 58 |
+
runByDefault?: boolean;
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
type ProbeResult = InitialProbeResult;
|
| 62 |
+
|
| 63 |
+
type GatewaySample = {
|
| 64 |
+
completionMs: number | null;
|
| 65 |
+
cpuCoreRatio: number | null;
|
| 66 |
+
cpuMs: number | null;
|
| 67 |
+
exitedBeforeTeardown?: boolean;
|
| 68 |
+
exitCode: number | null;
|
| 69 |
+
firstOutputMs: number | null;
|
| 70 |
+
gatewayReadyLogLine: string | null;
|
| 71 |
+
gatewayReadyLogMs: number | null;
|
| 72 |
+
healthz: ProbeResult;
|
| 73 |
+
httpListenLogLine: string | null;
|
| 74 |
+
httpListenLogMs: number | null;
|
| 75 |
+
maxRssMb: number | null;
|
| 76 |
+
outputTail: string;
|
| 77 |
+
readyz: ProbeResult;
|
| 78 |
+
signal: string | null;
|
| 79 |
+
startupTrace: Record<string, number>;
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
type CaseResult = {
|
| 83 |
+
id: string;
|
| 84 |
+
name: string;
|
| 85 |
+
samples: GatewaySample[];
|
| 86 |
+
summary: {
|
| 87 |
+
completionMs: SummaryStats | null;
|
| 88 |
+
firstOutputMs: SummaryStats | null;
|
| 89 |
+
cpuCoreRatio: SummaryStats | null;
|
| 90 |
+
cpuMs: SummaryStats | null;
|
| 91 |
+
gatewayReadyLogMs: SummaryStats | null;
|
| 92 |
+
healthzMs: SummaryStats | null;
|
| 93 |
+
httpListenLogMs: SummaryStats | null;
|
| 94 |
+
maxRssMb: SummaryStats | null;
|
| 95 |
+
readyzMs: SummaryStats | null;
|
| 96 |
+
startupTrace: Record<string, SummaryStats>;
|
| 97 |
+
};
|
| 98 |
+
};
|
| 99 |
+
|
| 100 |
+
type BenchmarkFailure = {
|
| 101 |
+
id: string;
|
| 102 |
+
reason: string;
|
| 103 |
+
sampleIndex: number;
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
type CliOptions = {
|
| 107 |
+
cases: GatewayBenchCase[];
|
| 108 |
+
cpuProfDir?: string;
|
| 109 |
+
entry: string;
|
| 110 |
+
heapProfDir?: string;
|
| 111 |
+
json: boolean;
|
| 112 |
+
output?: string;
|
| 113 |
+
runs: number;
|
| 114 |
+
timeoutMs: number;
|
| 115 |
+
warmup: number;
|
| 116 |
+
};
|
| 117 |
+
|
| 118 |
+
const DEFAULT_RUNS = 5;
|
| 119 |
+
const DEFAULT_WARMUP = 1;
|
| 120 |
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
| 121 |
+
const DEFAULT_ENTRY = "dist/entry.js";
|
| 122 |
+
const INCIDENT_COMBINED_HEALTHZ_P95_MAX_MS = 30_000;
|
| 123 |
+
const INCIDENT_COMBINED_READYZ_P95_MAX_MS = 60_000;
|
| 124 |
+
const BOOLEAN_FLAGS = new Set(["--help", "-h", "--json"]);
|
| 125 |
+
const VALUE_FLAGS = new Set([
|
| 126 |
+
"--case",
|
| 127 |
+
"--cpu-prof-dir",
|
| 128 |
+
"--entry",
|
| 129 |
+
"--heap-prof-dir",
|
| 130 |
+
"--output",
|
| 131 |
+
"--runs",
|
| 132 |
+
"--timeout-ms",
|
| 133 |
+
"--warmup",
|
| 134 |
+
]);
|
| 135 |
+
|
| 136 |
+
const BASE_CONFIG = BASE_GATEWAY_BENCH_CONFIG;
|
| 137 |
+
const LARGE_PLUGIN_MODEL_AGENT_COUNT = 256;
|
| 138 |
+
const LARGE_PLUGIN_MODEL_COUNT = 58;
|
| 139 |
+
const LARGE_PLUGIN_MODEL_PROVIDER_IDS = ["openai", "google", "minimax"] as const;
|
| 140 |
+
|
| 141 |
+
function buildLargePluginModelAgentEntries() {
|
| 142 |
+
const model = {
|
| 143 |
+
primary: `${LARGE_PLUGIN_MODEL_PROVIDER_IDS[0]}/model-01`,
|
| 144 |
+
fallbacks: Array.from({ length: LARGE_PLUGIN_MODEL_COUNT - 1 }, (_, offset) => {
|
| 145 |
+
const index = offset + 1;
|
| 146 |
+
const provider =
|
| 147 |
+
index % 3 === 0
|
| 148 |
+
? LARGE_PLUGIN_MODEL_PROVIDER_IDS[0]
|
| 149 |
+
: index % 3 === 1
|
| 150 |
+
? LARGE_PLUGIN_MODEL_PROVIDER_IDS[1]
|
| 151 |
+
: LARGE_PLUGIN_MODEL_PROVIDER_IDS[2];
|
| 152 |
+
return `${provider}/model-${String(index + 1).padStart(2, "0")}`;
|
| 153 |
+
}),
|
| 154 |
+
};
|
| 155 |
+
return Object.fromEntries(
|
| 156 |
+
Array.from({ length: LARGE_PLUGIN_MODEL_AGENT_COUNT }, (_, index) => [
|
| 157 |
+
`agent-${String(index + 1).padStart(4, "0")}`,
|
| 158 |
+
{ model },
|
| 159 |
+
]),
|
| 160 |
+
);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
const GATEWAY_CASES: readonly GatewayBenchCase[] = [
|
| 164 |
+
{
|
| 165 |
+
id: "default",
|
| 166 |
+
name: "gateway default",
|
| 167 |
+
config: BASE_CONFIG,
|
| 168 |
+
},
|
| 169 |
+
{
|
| 170 |
+
id: "skipChannels",
|
| 171 |
+
name: "gateway, skip channels",
|
| 172 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 173 |
+
config: BASE_CONFIG,
|
| 174 |
+
},
|
| 175 |
+
{
|
| 176 |
+
id: "preparedRuntimeCatalogStall",
|
| 177 |
+
name: "gateway, prepared runtime with CPU-stalling live catalog",
|
| 178 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 179 |
+
providerCatalogStallMs: 2_000,
|
| 180 |
+
config: {
|
| 181 |
+
...BASE_CONFIG,
|
| 182 |
+
agents: {
|
| 183 |
+
defaults: {
|
| 184 |
+
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
| 185 |
+
models: {
|
| 186 |
+
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
| 187 |
+
agentRuntime: { id: "openclaw" },
|
| 188 |
+
},
|
| 189 |
+
},
|
| 190 |
+
},
|
| 191 |
+
},
|
| 192 |
+
},
|
| 193 |
+
},
|
| 194 |
+
{
|
| 195 |
+
id: "preparedRuntimeScaleOne",
|
| 196 |
+
name: "gateway, prepared runtime scale with one agent",
|
| 197 |
+
agentTopology: "single",
|
| 198 |
+
completionTracePhase: "sidecars.ready",
|
| 199 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 200 |
+
providerStaticCatalogModelCount: 64,
|
| 201 |
+
providerStaticCatalogStallMs: 100,
|
| 202 |
+
config: {
|
| 203 |
+
...BASE_CONFIG,
|
| 204 |
+
agents: {
|
| 205 |
+
defaults: {
|
| 206 |
+
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
| 207 |
+
models: {
|
| 208 |
+
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
| 209 |
+
agentRuntime: { id: "openclaw" },
|
| 210 |
+
},
|
| 211 |
+
},
|
| 212 |
+
},
|
| 213 |
+
},
|
| 214 |
+
},
|
| 215 |
+
},
|
| 216 |
+
{
|
| 217 |
+
id: "preparedRuntimeScaleMany",
|
| 218 |
+
name: "gateway, prepared runtime scale with 11 shared-workspace agents and one distinct",
|
| 219 |
+
agentTopology: "shared-eleven-plus-distinct-one",
|
| 220 |
+
completionTracePhase: "sidecars.ready",
|
| 221 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 222 |
+
providerStaticCatalogModelCount: 64,
|
| 223 |
+
providerStaticCatalogStallMs: 100,
|
| 224 |
+
config: {
|
| 225 |
+
...BASE_CONFIG,
|
| 226 |
+
agents: {
|
| 227 |
+
defaults: {
|
| 228 |
+
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
| 229 |
+
models: {
|
| 230 |
+
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
| 231 |
+
agentRuntime: { id: "openclaw" },
|
| 232 |
+
},
|
| 233 |
+
},
|
| 234 |
+
},
|
| 235 |
+
},
|
| 236 |
+
},
|
| 237 |
+
},
|
| 238 |
+
{
|
| 239 |
+
id: "oneInternalHook",
|
| 240 |
+
name: "gateway, one configured internal hook",
|
| 241 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 242 |
+
config: {
|
| 243 |
+
...BASE_CONFIG,
|
| 244 |
+
hooks: {
|
| 245 |
+
internal: {
|
| 246 |
+
entries: {
|
| 247 |
+
"session-memory": { enabled: true },
|
| 248 |
+
},
|
| 249 |
+
},
|
| 250 |
+
},
|
| 251 |
+
},
|
| 252 |
+
},
|
| 253 |
+
{
|
| 254 |
+
id: "allInternalHooks",
|
| 255 |
+
name: "gateway, all internal hooks",
|
| 256 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 257 |
+
config: {
|
| 258 |
+
...BASE_CONFIG,
|
| 259 |
+
hooks: {
|
| 260 |
+
internal: {
|
| 261 |
+
enabled: true,
|
| 262 |
+
},
|
| 263 |
+
},
|
| 264 |
+
},
|
| 265 |
+
},
|
| 266 |
+
{
|
| 267 |
+
id: "fiftyPlugins",
|
| 268 |
+
name: "gateway, 50 manifest plugins",
|
| 269 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 270 |
+
pluginActivationOnStartup: true,
|
| 271 |
+
pluginCount: 50,
|
| 272 |
+
config: BASE_CONFIG,
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
id: "fiftyStartupLazyPlugins",
|
| 276 |
+
name: "gateway, 50 startup-lazy manifest plugins",
|
| 277 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 278 |
+
pluginActivationOnStartup: false,
|
| 279 |
+
pluginCount: 50,
|
| 280 |
+
config: BASE_CONFIG,
|
| 281 |
+
},
|
| 282 |
+
{
|
| 283 |
+
id: "largePluginModelConfig",
|
| 284 |
+
name: `gateway, ${LARGE_PLUGIN_MODEL_AGENT_COUNT} agents with ${LARGE_PLUGIN_MODEL_COUNT} plugin-owned model refs each`,
|
| 285 |
+
completionTracePhase: "config.snapshot.auto-enable",
|
| 286 |
+
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
| 287 |
+
runByDefault: false,
|
| 288 |
+
config: {
|
| 289 |
+
...BASE_CONFIG,
|
| 290 |
+
agents: {
|
| 291 |
+
ownership: "explicit",
|
| 292 |
+
entries: buildLargePluginModelAgentEntries(),
|
| 293 |
+
},
|
| 294 |
+
plugins: {
|
| 295 |
+
enabled: true,
|
| 296 |
+
allow: [...LARGE_PLUGIN_MODEL_PROVIDER_IDS],
|
| 297 |
+
},
|
| 298 |
+
},
|
| 299 |
+
},
|
| 300 |
+
{
|
| 301 |
+
id: "incidentDatabase",
|
| 302 |
+
name: "gateway, incident-scale database",
|
| 303 |
+
incidentFixture: "database",
|
| 304 |
+
runByDefault: false,
|
| 305 |
+
config: BASE_CONFIG,
|
| 306 |
+
},
|
| 307 |
+
{
|
| 308 |
+
id: "incidentNullMetadata",
|
| 309 |
+
name: "gateway, incident-scale NULL metadata repair",
|
| 310 |
+
incidentFixture: "null-metadata",
|
| 311 |
+
runByDefault: false,
|
| 312 |
+
config: BASE_CONFIG,
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
id: "incidentWorkspace",
|
| 316 |
+
name: "gateway, incident-scale workspace",
|
| 317 |
+
agentTopology: "incident-scale",
|
| 318 |
+
incidentFixture: "workspace",
|
| 319 |
+
runByDefault: false,
|
| 320 |
+
config: BASE_CONFIG,
|
| 321 |
+
},
|
| 322 |
+
{
|
| 323 |
+
id: "incidentPackagedPlugins",
|
| 324 |
+
name: "gateway, packaged plugin inventory",
|
| 325 |
+
env: { OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1" },
|
| 326 |
+
incidentFixture: "packaged-plugins",
|
| 327 |
+
runByDefault: false,
|
| 328 |
+
config: BASE_CONFIG,
|
| 329 |
+
},
|
| 330 |
+
{
|
| 331 |
+
id: "incidentCombined",
|
| 332 |
+
name: "gateway, incident-scale combined load",
|
| 333 |
+
agentTopology: "incident-scale",
|
| 334 |
+
env: { OPENCLAW_DISABLE_BUNDLED_ENTRY_SOURCE_FALLBACK: "1" },
|
| 335 |
+
incidentFixture: "combined",
|
| 336 |
+
runByDefault: false,
|
| 337 |
+
config: BASE_CONFIG,
|
| 338 |
+
},
|
| 339 |
+
] as const;
|
| 340 |
+
|
| 341 |
+
function validateCliArgs(argv: string[]): void {
|
| 342 |
+
validateGatewayBenchCliArgs(argv, {
|
| 343 |
+
booleanFlags: BOOLEAN_FLAGS,
|
| 344 |
+
repeatableValueFlags: new Set(["--case"]),
|
| 345 |
+
valueFlags: VALUE_FLAGS,
|
| 346 |
+
});
|
| 347 |
+
}
|
| 348 |
+
|
| 349 |
+
function resolveEntry(raw: string | undefined): string {
|
| 350 |
+
return resolveGatewayBenchEntry(raw, DEFAULT_ENTRY);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function resolveCases(caseIds: string[]): GatewayBenchCase[] {
|
| 354 |
+
const defaultCases = GATEWAY_CASES.filter((benchCase) => benchCase.runByDefault !== false);
|
| 355 |
+
return resolveGatewayBenchCases(caseIds, caseIds.length === 0 ? defaultCases : GATEWAY_CASES, {
|
| 356 |
+
allByDefault: true,
|
| 357 |
+
validateDuplicatesFirst: true,
|
| 358 |
+
});
|
| 359 |
+
}
|
| 360 |
+
|
| 361 |
+
function parseOptions(argv: string[] = process.argv.slice(2)): CliOptions {
|
| 362 |
+
validateCliArgs(argv);
|
| 363 |
+
return {
|
| 364 |
+
cases: resolveCases(parseRepeatableFlag(argv, "--case")),
|
| 365 |
+
cpuProfDir: parseFlagValue(argv, "--cpu-prof-dir"),
|
| 366 |
+
entry: resolveEntry(parseFlagValue(argv, "--entry")),
|
| 367 |
+
heapProfDir: parseFlagValue(argv, "--heap-prof-dir"),
|
| 368 |
+
json: hasFlag(argv, "--json"),
|
| 369 |
+
output: resolveOutputPath(parseFlagValue(argv, "--output")),
|
| 370 |
+
runs: parsePositiveInt(parseFlagValue(argv, "--runs"), DEFAULT_RUNS, "--runs"),
|
| 371 |
+
timeoutMs: parsePositiveInt(
|
| 372 |
+
parseFlagValue(argv, "--timeout-ms"),
|
| 373 |
+
DEFAULT_TIMEOUT_MS,
|
| 374 |
+
"--timeout-ms",
|
| 375 |
+
),
|
| 376 |
+
warmup: parseNonNegativeInt(parseFlagValue(argv, "--warmup"), DEFAULT_WARMUP, "--warmup"),
|
| 377 |
+
};
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
function printUsage(): void {
|
| 381 |
+
console.log(`OpenClaw Gateway startup benchmark
|
| 382 |
+
|
| 383 |
+
Usage:
|
| 384 |
+
pnpm test:startup:gateway -- [options]
|
| 385 |
+
node --import tsx scripts/bench-gateway-startup.ts [options]
|
| 386 |
+
|
| 387 |
+
Options:
|
| 388 |
+
--case <id> Specific case id to run; repeatable
|
| 389 |
+
--entry <path> Gateway CLI entry file (default: ${DEFAULT_ENTRY})
|
| 390 |
+
--runs <n> Measured runs per case (default: ${DEFAULT_RUNS})
|
| 391 |
+
--warmup <n> Warmup runs per case (default: ${DEFAULT_WARMUP})
|
| 392 |
+
--timeout-ms <ms> Per-run timeout (default: ${DEFAULT_TIMEOUT_MS})
|
| 393 |
+
--cpu-prof-dir <dir> Write one V8 CPU profile per run
|
| 394 |
+
--heap-prof-dir <dir> Write one V8 heap profile per run
|
| 395 |
+
--output <path> Write machine-readable JSON to a file
|
| 396 |
+
--json Emit machine-readable JSON
|
| 397 |
+
--help, -h Show this text
|
| 398 |
+
|
| 399 |
+
Case ids:
|
| 400 |
+
${GATEWAY_CASES.map((benchCase) => `${benchCase.id} (${benchCase.name})`).join("\n ")}
|
| 401 |
+
`);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
function summarizeCase(benchCase: GatewayBenchCase, samples: GatewaySample[]): CaseResult {
|
| 405 |
+
const startupTrace = summarizeTraceStats(samples, (sample) => sample.startupTrace);
|
| 406 |
+
return {
|
| 407 |
+
id: benchCase.id,
|
| 408 |
+
name: benchCase.name,
|
| 409 |
+
samples,
|
| 410 |
+
summary: {
|
| 411 |
+
completionMs: summarizeNumbers(
|
| 412 |
+
samples
|
| 413 |
+
.map((sample) => sample.completionMs)
|
| 414 |
+
.filter((value): value is number => typeof value === "number"),
|
| 415 |
+
),
|
| 416 |
+
firstOutputMs: summarizeNumbers(
|
| 417 |
+
samples
|
| 418 |
+
.map((sample) => sample.firstOutputMs)
|
| 419 |
+
.filter((value): value is number => typeof value === "number"),
|
| 420 |
+
),
|
| 421 |
+
cpuCoreRatio: summarizeNumbers(
|
| 422 |
+
samples
|
| 423 |
+
.map((sample) => sample.cpuCoreRatio)
|
| 424 |
+
.filter((value): value is number => typeof value === "number"),
|
| 425 |
+
),
|
| 426 |
+
cpuMs: summarizeNumbers(
|
| 427 |
+
samples
|
| 428 |
+
.map((sample) => sample.cpuMs)
|
| 429 |
+
.filter((value): value is number => typeof value === "number"),
|
| 430 |
+
),
|
| 431 |
+
gatewayReadyLogMs: summarizeNumbers(
|
| 432 |
+
samples
|
| 433 |
+
.map((sample) => sample.gatewayReadyLogMs)
|
| 434 |
+
.filter((value): value is number => typeof value === "number"),
|
| 435 |
+
),
|
| 436 |
+
healthzMs: summarizeNumbers(
|
| 437 |
+
samples
|
| 438 |
+
.map((sample) => sample.healthz.ms)
|
| 439 |
+
.filter((value): value is number => typeof value === "number"),
|
| 440 |
+
),
|
| 441 |
+
httpListenLogMs: summarizeNumbers(
|
| 442 |
+
samples
|
| 443 |
+
.map((sample) => sample.httpListenLogMs)
|
| 444 |
+
.filter((value): value is number => typeof value === "number"),
|
| 445 |
+
),
|
| 446 |
+
maxRssMb: summarizeNumbers(
|
| 447 |
+
samples
|
| 448 |
+
.map((sample) => sample.maxRssMb)
|
| 449 |
+
.filter((value): value is number => typeof value === "number"),
|
| 450 |
+
),
|
| 451 |
+
readyzMs: summarizeNumbers(
|
| 452 |
+
samples
|
| 453 |
+
.map((sample) => sample.readyz.ms)
|
| 454 |
+
.filter((value): value is number => typeof value === "number"),
|
| 455 |
+
),
|
| 456 |
+
startupTrace,
|
| 457 |
+
},
|
| 458 |
+
};
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
function collectResultFailures(results: CaseResult[]): BenchmarkFailure[] {
|
| 462 |
+
const failures: BenchmarkFailure[] = [];
|
| 463 |
+
for (const result of results) {
|
| 464 |
+
result.samples.forEach((sample, index) => {
|
| 465 |
+
const missing: string[] = [];
|
| 466 |
+
if (sample.healthz.status !== 200 || sample.healthz.ms == null) {
|
| 467 |
+
missing.push("/healthz");
|
| 468 |
+
}
|
| 469 |
+
if (sample.readyz.status !== 200 || sample.readyz.ms == null) {
|
| 470 |
+
missing.push("/readyz");
|
| 471 |
+
}
|
| 472 |
+
if (sample.completionMs == null) {
|
| 473 |
+
missing.push("completion");
|
| 474 |
+
}
|
| 475 |
+
if (sample.cpuMs == null || sample.cpuCoreRatio == null) {
|
| 476 |
+
missing.push("cpu");
|
| 477 |
+
}
|
| 478 |
+
if (sample.maxRssMb == null) {
|
| 479 |
+
missing.push("rss");
|
| 480 |
+
}
|
| 481 |
+
if (missing.length > 0) {
|
| 482 |
+
failures.push({
|
| 483 |
+
id: result.id,
|
| 484 |
+
reason: `missing ${missing.join(", ")}`,
|
| 485 |
+
sampleIndex: index + 1,
|
| 486 |
+
});
|
| 487 |
+
return;
|
| 488 |
+
}
|
| 489 |
+
if (sample.exitedBeforeTeardown === true) {
|
| 490 |
+
failures.push({
|
| 491 |
+
id: result.id,
|
| 492 |
+
reason:
|
| 493 |
+
sample.signal == null
|
| 494 |
+
? `child exited ${sample.exitCode ?? "before teardown"}`
|
| 495 |
+
: `child exited by ${sample.signal}`,
|
| 496 |
+
sampleIndex: index + 1,
|
| 497 |
+
});
|
| 498 |
+
}
|
| 499 |
+
});
|
| 500 |
+
if (result.id !== "incidentCombined") {
|
| 501 |
+
continue;
|
| 502 |
+
}
|
| 503 |
+
const healthzP95 = result.summary.healthzMs?.p95;
|
| 504 |
+
if (healthzP95 == null || healthzP95 >= INCIDENT_COMBINED_HEALTHZ_P95_MAX_MS) {
|
| 505 |
+
failures.push({
|
| 506 |
+
id: result.id,
|
| 507 |
+
reason:
|
| 508 |
+
`/healthz p95 ${healthzP95 == null ? "missing" : formatMs(healthzP95)} ` +
|
| 509 |
+
`must be under ${formatMs(INCIDENT_COMBINED_HEALTHZ_P95_MAX_MS)}`,
|
| 510 |
+
sampleIndex: 0,
|
| 511 |
+
});
|
| 512 |
+
}
|
| 513 |
+
const readyzP95 = result.summary.readyzMs?.p95;
|
| 514 |
+
if (readyzP95 == null || readyzP95 >= INCIDENT_COMBINED_READYZ_P95_MAX_MS) {
|
| 515 |
+
failures.push({
|
| 516 |
+
id: result.id,
|
| 517 |
+
reason:
|
| 518 |
+
`/readyz p95 ${readyzP95 == null ? "missing" : formatMs(readyzP95)} ` +
|
| 519 |
+
`must be under ${formatMs(INCIDENT_COMBINED_READYZ_P95_MAX_MS)}`,
|
| 520 |
+
sampleIndex: 0,
|
| 521 |
+
});
|
| 522 |
+
}
|
| 523 |
+
}
|
| 524 |
+
return failures;
|
| 525 |
+
}
|
| 526 |
+
|
| 527 |
+
function printBenchmarkFailures(failures: BenchmarkFailure[]): void {
|
| 528 |
+
if (failures.length === 0) {
|
| 529 |
+
return;
|
| 530 |
+
}
|
| 531 |
+
console.error(
|
| 532 |
+
`[gateway-startup-bench] failed: ${failures.length} sample(s) did not produce ready probes or process metrics`,
|
| 533 |
+
);
|
| 534 |
+
for (const failure of failures.slice(0, 8)) {
|
| 535 |
+
console.error(
|
| 536 |
+
`[gateway-startup-bench] ${failure.id} ${failure.sampleIndex ? `run ${failure.sampleIndex}` : "summary"}: ${failure.reason}`,
|
| 537 |
+
);
|
| 538 |
+
}
|
| 539 |
+
if (failures.length > 8) {
|
| 540 |
+
console.error(`[gateway-startup-bench] ${failures.length - 8} more sample failure(s) omitted`);
|
| 541 |
+
}
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
function formatRatio(value: number | null): string {
|
| 545 |
+
if (value == null) {
|
| 546 |
+
return "n/a";
|
| 547 |
+
}
|
| 548 |
+
return value.toFixed(3);
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
async function waitForStartupTracePhase(params: {
|
| 552 |
+
deadlineAt: number;
|
| 553 |
+
isDone: () => boolean;
|
| 554 |
+
phase: string;
|
| 555 |
+
startupTrace: Record<string, number>;
|
| 556 |
+
}): Promise<number | null> {
|
| 557 |
+
const totalKey = `${params.phase}.total`;
|
| 558 |
+
while (performance.now() < params.deadlineAt) {
|
| 559 |
+
if (Object.hasOwn(params.startupTrace, totalKey)) {
|
| 560 |
+
return params.startupTrace[totalKey] ?? null;
|
| 561 |
+
}
|
| 562 |
+
if (params.isDone()) {
|
| 563 |
+
return null;
|
| 564 |
+
}
|
| 565 |
+
await delay(25);
|
| 566 |
+
}
|
| 567 |
+
return null;
|
| 568 |
+
}
|
| 569 |
+
|
| 570 |
+
function buildBenchAgentList(
|
| 571 |
+
root: string,
|
| 572 |
+
topology: GatewayBenchCase["agentTopology"],
|
| 573 |
+
): Array<{ id: string; default?: boolean; workspace: string }> | undefined {
|
| 574 |
+
if (!topology) {
|
| 575 |
+
return undefined;
|
| 576 |
+
}
|
| 577 |
+
const sharedWorkspace = path.join(root, "shared-workspace");
|
| 578 |
+
const distinctWorkspace = path.join(root, "distinct-workspace");
|
| 579 |
+
mkdirSync(sharedWorkspace, { recursive: true });
|
| 580 |
+
if (topology === "single") {
|
| 581 |
+
return [{ id: "main", default: true, workspace: sharedWorkspace }];
|
| 582 |
+
}
|
| 583 |
+
if (topology === "incident-scale") {
|
| 584 |
+
return Array.from({ length: 8 }, (_, index) => ({
|
| 585 |
+
id: `incident-agent-${String(index + 1).padStart(2, "0")}`,
|
| 586 |
+
default: index === 0,
|
| 587 |
+
workspace: path.join(root, "workspaces", `agent-${String(index + 1).padStart(2, "0")}`),
|
| 588 |
+
}));
|
| 589 |
+
}
|
| 590 |
+
mkdirSync(distinctWorkspace, { recursive: true });
|
| 591 |
+
return Array.from({ length: 12 }, (_, index) => ({
|
| 592 |
+
id: `agent-${String(index + 1).padStart(2, "0")}`,
|
| 593 |
+
...(index === 0 ? { default: true } : {}),
|
| 594 |
+
workspace: index === 11 ? distinctWorkspace : sharedWorkspace,
|
| 595 |
+
}));
|
| 596 |
+
}
|
| 597 |
+
|
| 598 |
+
const INCIDENT_AUDIT_ROW_COUNT = 200_000;
|
| 599 |
+
const INCIDENT_WORKSPACE_FILE_BYTES = 10 * 1024;
|
| 600 |
+
const INCIDENT_WORKSPACE_FILE_COUNT = 80_000;
|
| 601 |
+
const INCIDENT_WORKSPACE_COUNT = 8;
|
| 602 |
+
const INCIDENT_WORKSPACE_FILES_PER_DIRECTORY = 1_000;
|
| 603 |
+
|
| 604 |
+
type IncidentFixtureOptions = {
|
| 605 |
+
auditRowCount?: number;
|
| 606 |
+
kind: NonNullable<GatewayBenchCase["incidentFixture"]>;
|
| 607 |
+
workspaceFileBytes?: number;
|
| 608 |
+
workspaceFileCount?: number;
|
| 609 |
+
};
|
| 610 |
+
|
| 611 |
+
function fixtureIncludesDatabase(kind: IncidentFixtureOptions["kind"]): boolean {
|
| 612 |
+
return kind === "combined" || kind === "database" || kind === "null-metadata";
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
function fixtureIncludesWorkspace(kind: IncidentFixtureOptions["kind"]): boolean {
|
| 616 |
+
return kind === "combined" || kind === "workspace";
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
function fixtureIncludesNullMetadata(kind: IncidentFixtureOptions["kind"]): boolean {
|
| 620 |
+
return kind === "combined" || kind === "null-metadata";
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
function fixtureIncludesPackagedPlugins(kind: IncidentFixtureOptions["kind"]): boolean {
|
| 624 |
+
return kind === "combined" || kind === "packaged-plugins";
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
function listIncidentPackagedPluginArtifacts(env = process.env): string[] {
|
| 628 |
+
return listBundledPluginPackArtifacts({
|
| 629 |
+
env: { ...env, OPENCLAW_BUNDLED_PLUGIN_BUILD_IDS: undefined },
|
| 630 |
+
});
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
function assertIncidentPackagedPluginInventory(): void {
|
| 634 |
+
const missing = listIncidentPackagedPluginArtifacts().filter(
|
| 635 |
+
(artifact) => !existsSync(path.resolve(process.cwd(), artifact)),
|
| 636 |
+
);
|
| 637 |
+
if (missing.length > 0) {
|
| 638 |
+
throw new Error(
|
| 639 |
+
`Incident packaged-plugin benchmark requires a built plugin inventory; ${missing.length} required artifact(s) are missing. Run pnpm build first.`,
|
| 640 |
+
);
|
| 641 |
+
}
|
| 642 |
+
}
|
| 643 |
+
|
| 644 |
+
function createIncidentWorkspaces(root: string, options: IncidentFixtureOptions): void {
|
| 645 |
+
if (!fixtureIncludesWorkspace(options.kind)) {
|
| 646 |
+
return;
|
| 647 |
+
}
|
| 648 |
+
const fileCount = options.workspaceFileCount ?? INCIDENT_WORKSPACE_FILE_COUNT;
|
| 649 |
+
const fileBytes = options.workspaceFileBytes ?? INCIDENT_WORKSPACE_FILE_BYTES;
|
| 650 |
+
const contents = Buffer.alloc(fileBytes, "x");
|
| 651 |
+
for (let index = 0; index < fileCount; index += 1) {
|
| 652 |
+
const workspaceIndex = index % INCIDENT_WORKSPACE_COUNT;
|
| 653 |
+
const directoryIndex = Math.floor(index / INCIDENT_WORKSPACE_FILES_PER_DIRECTORY);
|
| 654 |
+
const directory = path.join(
|
| 655 |
+
root,
|
| 656 |
+
"workspaces",
|
| 657 |
+
`agent-${String(workspaceIndex + 1).padStart(2, "0")}`,
|
| 658 |
+
"incident-artifacts",
|
| 659 |
+
`batch-${String(directoryIndex).padStart(3, "0")}`,
|
| 660 |
+
);
|
| 661 |
+
mkdirSync(directory, { recursive: true });
|
| 662 |
+
writeFileSync(path.join(directory, `artifact-${String(index).padStart(6, "0")}.bin`), contents);
|
| 663 |
+
}
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
function seedIncidentAuditRows(database: import("node:sqlite").DatabaseSync, count: number): void {
|
| 667 |
+
const payload = `fixture:${"x".repeat(1024)}`;
|
| 668 |
+
const insert = database.prepare(
|
| 669 |
+
`INSERT INTO audit_events (
|
| 670 |
+
event_id, source_id, source_sequence, occurred_at, kind, action, status, actor_type,
|
| 671 |
+
actor_id, agent_id, session_key, run_id, channel, target_ref
|
| 672 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
| 673 |
+
);
|
| 674 |
+
database.exec("BEGIN IMMEDIATE;");
|
| 675 |
+
try {
|
| 676 |
+
for (let index = 0; index < count; index += 1) {
|
| 677 |
+
const id = `incident-audit-${String(index).padStart(8, "0")}`;
|
| 678 |
+
insert.run(
|
| 679 |
+
id,
|
| 680 |
+
`source-${id}`,
|
| 681 |
+
index + 1,
|
| 682 |
+
1_750_000_000_000 + index,
|
| 683 |
+
"gateway.admission",
|
| 684 |
+
"accepted",
|
| 685 |
+
"ok",
|
| 686 |
+
"system",
|
| 687 |
+
"incident-fixture",
|
| 688 |
+
"incident-agent-01",
|
| 689 |
+
"agent:incident-agent-01:main",
|
| 690 |
+
`run-${id}`,
|
| 691 |
+
"fixture",
|
| 692 |
+
payload,
|
| 693 |
+
);
|
| 694 |
+
}
|
| 695 |
+
database.exec("COMMIT;");
|
| 696 |
+
} catch (error) {
|
| 697 |
+
database.exec("ROLLBACK;");
|
| 698 |
+
throw error;
|
| 699 |
+
}
|
| 700 |
+
// Retain half the rows after a large write so the fixture keeps the incident's
|
| 701 |
+
// high freelist pressure without storing any customer content.
|
| 702 |
+
database.prepare("DELETE FROM audit_events WHERE source_sequence % 2 = 0").run();
|
| 703 |
+
}
|
| 704 |
+
|
| 705 |
+
async function writeIncidentFixture(root: string, options: IncidentFixtureOptions): Promise<void> {
|
| 706 |
+
createIncidentWorkspaces(root, options);
|
| 707 |
+
if (!fixtureIncludesDatabase(options.kind)) {
|
| 708 |
+
return;
|
| 709 |
+
}
|
| 710 |
+
const env = { OPENCLAW_STATE_DIR: path.join(root, "state") };
|
| 711 |
+
const [{ DatabaseSync }, agentDatabase, stateDatabase] = await Promise.all([
|
| 712 |
+
import("node:sqlite"),
|
| 713 |
+
import("../src/state/openclaw-agent-db.js"),
|
| 714 |
+
import("../src/state/openclaw-state-db.js"),
|
| 715 |
+
]);
|
| 716 |
+
const state = stateDatabase.openOpenClawStateDatabase({ env });
|
| 717 |
+
const agents = Array.from({ length: INCIDENT_WORKSPACE_COUNT }, (_, index) =>
|
| 718 |
+
agentDatabase.openOpenClawAgentDatabase({
|
| 719 |
+
agentId: `incident-agent-${String(index + 1).padStart(2, "0")}`,
|
| 720 |
+
env,
|
| 721 |
+
}),
|
| 722 |
+
);
|
| 723 |
+
const statePath = state.path;
|
| 724 |
+
const agentPaths = agents.map((agent) => agent.path);
|
| 725 |
+
try {
|
| 726 |
+
state.db.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
| 727 |
+
agents.forEach((agent) => agent.db.exec("PRAGMA wal_checkpoint(TRUNCATE);"));
|
| 728 |
+
} finally {
|
| 729 |
+
agentDatabase.closeOpenClawAgentDatabasesForTest();
|
| 730 |
+
stateDatabase.closeOpenClawStateDatabaseForTest();
|
| 731 |
+
}
|
| 732 |
+
const stateHandle = new DatabaseSync(statePath);
|
| 733 |
+
try {
|
| 734 |
+
seedIncidentAuditRows(stateHandle, options.auditRowCount ?? INCIDENT_AUDIT_ROW_COUNT);
|
| 735 |
+
if (fixtureIncludesNullMetadata(options.kind)) {
|
| 736 |
+
stateHandle
|
| 737 |
+
.prepare("UPDATE schema_meta SET app_version = NULL WHERE meta_key = 'primary'")
|
| 738 |
+
.run();
|
| 739 |
+
}
|
| 740 |
+
stateHandle.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
| 741 |
+
} finally {
|
| 742 |
+
stateHandle.close();
|
| 743 |
+
}
|
| 744 |
+
if (!fixtureIncludesNullMetadata(options.kind)) {
|
| 745 |
+
return;
|
| 746 |
+
}
|
| 747 |
+
for (const agentPath of agentPaths) {
|
| 748 |
+
const agent = new DatabaseSync(agentPath);
|
| 749 |
+
try {
|
| 750 |
+
agent.prepare("UPDATE schema_meta SET app_version = NULL WHERE meta_key = 'primary'").run();
|
| 751 |
+
agent.exec("PRAGMA wal_checkpoint(TRUNCATE);");
|
| 752 |
+
} finally {
|
| 753 |
+
agent.close();
|
| 754 |
+
}
|
| 755 |
+
}
|
| 756 |
+
}
|
| 757 |
+
|
| 758 |
+
async function withGatewayBenchRoot<T>(run: (root: string) => Promise<T>): Promise<T> {
|
| 759 |
+
const root = mkdtempSync(path.join(tmpdir(), "openclaw-gateway-bench-"));
|
| 760 |
+
try {
|
| 761 |
+
return await run(root);
|
| 762 |
+
} finally {
|
| 763 |
+
rmSync(root, { force: true, maxRetries: 3, recursive: true, retryDelay: 100 });
|
| 764 |
+
}
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
function writeConfig(root: string, benchCase: GatewayBenchCase): string {
|
| 768 |
+
const hasCatalogFixture =
|
| 769 |
+
benchCase.providerCatalogStallMs !== undefined ||
|
| 770 |
+
benchCase.providerStaticCatalogStallMs !== undefined;
|
| 771 |
+
const pluginCount = hasCatalogFixture ? 1 : benchCase.pluginCount;
|
| 772 |
+
const pluginFixtures = pluginCount
|
| 773 |
+
? writePluginFixtures(root, {
|
| 774 |
+
activationOnStartup: hasCatalogFixture ? true : benchCase.pluginActivationOnStartup,
|
| 775 |
+
count: pluginCount,
|
| 776 |
+
providerCatalogStallMs: benchCase.providerCatalogStallMs,
|
| 777 |
+
providerStaticCatalogModelCount: benchCase.providerStaticCatalogModelCount,
|
| 778 |
+
providerStaticCatalogStallMs: benchCase.providerStaticCatalogStallMs,
|
| 779 |
+
})
|
| 780 |
+
: null;
|
| 781 |
+
const agentList = buildBenchAgentList(root, benchCase.agentTopology);
|
| 782 |
+
return writeGatewayBenchConfig(root, benchCase.config, { agentList, pluginFixtures });
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
function sanitizedEnv(
|
| 786 |
+
root: string,
|
| 787 |
+
configPath: string,
|
| 788 |
+
benchCase: GatewayBenchCase,
|
| 789 |
+
): NodeJS.ProcessEnv {
|
| 790 |
+
return createGatewayBenchEnv(root, configPath, { caseEnv: benchCase.env });
|
| 791 |
+
}
|
| 792 |
+
|
| 793 |
+
function collectStartupTrace(line: string, startupTrace: Record<string, number>): void {
|
| 794 |
+
collectTraceLine(line, "startup trace", startupTrace);
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
async function runGatewaySample(options: {
|
| 798 |
+
benchCase: GatewayBenchCase;
|
| 799 |
+
cpuProfDir?: string;
|
| 800 |
+
entry: string;
|
| 801 |
+
heapProfDir?: string;
|
| 802 |
+
sampleIndex: number;
|
| 803 |
+
timeoutMs: number;
|
| 804 |
+
}): Promise<GatewaySample> {
|
| 805 |
+
return await withGatewayBenchRoot(async (root) => {
|
| 806 |
+
const port = await getFreePort();
|
| 807 |
+
const configPath = writeConfig(root, options.benchCase);
|
| 808 |
+
const env = sanitizedEnv(root, configPath, options.benchCase);
|
| 809 |
+
if (options.benchCase.incidentFixture) {
|
| 810 |
+
await writeIncidentFixture(root, { kind: options.benchCase.incidentFixture });
|
| 811 |
+
if (fixtureIncludesPackagedPlugins(options.benchCase.incidentFixture)) {
|
| 812 |
+
assertIncidentPackagedPluginInventory();
|
| 813 |
+
}
|
| 814 |
+
}
|
| 815 |
+
const startAt = performance.now();
|
| 816 |
+
const deadlineAt = startAt + options.timeoutMs;
|
| 817 |
+
const startupTrace: Record<string, number> = {};
|
| 818 |
+
const output: string[] = [];
|
| 819 |
+
const outputBuffers: Record<"stderr" | "stdout", string> = { stderr: "", stdout: "" };
|
| 820 |
+
let firstOutputMs: number | null = null;
|
| 821 |
+
let gatewayReadyLogLine: string | null = null;
|
| 822 |
+
let gatewayReadyLogMs: number | null = null;
|
| 823 |
+
let httpListenLogLine: string | null = null;
|
| 824 |
+
let httpListenLogMs: number | null = null;
|
| 825 |
+
let maxRssMb: number | null = null;
|
| 826 |
+
let childExited = false;
|
| 827 |
+
let child: ChildProcessWithoutNullStreams | undefined;
|
| 828 |
+
let rssTimer: ReturnType<typeof setInterval> | undefined;
|
| 829 |
+
|
| 830 |
+
try {
|
| 831 |
+
const nodeOptions = [
|
| 832 |
+
...(options.cpuProfDir
|
| 833 |
+
? [
|
| 834 |
+
"--cpu-prof",
|
| 835 |
+
"--cpu-prof-dir",
|
| 836 |
+
options.cpuProfDir,
|
| 837 |
+
"--cpu-prof-name",
|
| 838 |
+
`openclaw-gateway-${options.benchCase.id}-${options.sampleIndex}-${Date.now()}.cpuprofile`,
|
| 839 |
+
]
|
| 840 |
+
: []),
|
| 841 |
+
...(options.heapProfDir ? ["--heap-prof", "--heap-prof-dir", options.heapProfDir] : []),
|
| 842 |
+
];
|
| 843 |
+
const childArgs = buildGatewayBenchChildArgs(options.entry, port, nodeOptions);
|
| 844 |
+
child = spawn(process.execPath, childArgs, {
|
| 845 |
+
cwd: process.cwd(),
|
| 846 |
+
detached: process.platform !== "win32",
|
| 847 |
+
env,
|
| 848 |
+
stdio: ["pipe", "pipe", "pipe"],
|
| 849 |
+
});
|
| 850 |
+
const startedChild = child;
|
| 851 |
+
const cpuStartMs = readProcessTreeCpuMs(startedChild.pid);
|
| 852 |
+
const sampleRss = () => {
|
| 853 |
+
const rssMb = readProcessRssMb(startedChild.pid);
|
| 854 |
+
if (rssMb != null) {
|
| 855 |
+
maxRssMb = maxRssMb == null ? rssMb : Math.max(maxRssMb, rssMb);
|
| 856 |
+
}
|
| 857 |
+
};
|
| 858 |
+
sampleRss();
|
| 859 |
+
rssTimer = setInterval(sampleRss, 100);
|
| 860 |
+
rssTimer.unref?.();
|
| 861 |
+
const childExitPromise = new Promise<{ exitCode: number | null; signal: string | null }>(
|
| 862 |
+
(resolve) => {
|
| 863 |
+
startedChild.once("exit", (exitCode, signal) => {
|
| 864 |
+
childExited = true;
|
| 865 |
+
resolve({ exitCode, signal });
|
| 866 |
+
});
|
| 867 |
+
},
|
| 868 |
+
);
|
| 869 |
+
|
| 870 |
+
const onLine = (line: string, nowMs: number) => {
|
| 871 |
+
const readyLogKind = classifyGatewayReadyLog(line);
|
| 872 |
+
if (readyLogKind === "http-listen" && httpListenLogMs == null) {
|
| 873 |
+
httpListenLogMs = nowMs;
|
| 874 |
+
httpListenLogLine = line;
|
| 875 |
+
}
|
| 876 |
+
if (readyLogKind === "gateway-ready" && gatewayReadyLogMs == null) {
|
| 877 |
+
gatewayReadyLogMs = nowMs;
|
| 878 |
+
gatewayReadyLogLine = line;
|
| 879 |
+
}
|
| 880 |
+
collectStartupTrace(line, startupTrace);
|
| 881 |
+
};
|
| 882 |
+
const onChunk = (stream: "stderr" | "stdout", chunk: Buffer) => {
|
| 883 |
+
if (firstOutputMs == null) {
|
| 884 |
+
firstOutputMs = performance.now() - startAt;
|
| 885 |
+
}
|
| 886 |
+
const text = chunk.toString("utf8");
|
| 887 |
+
output.push(text);
|
| 888 |
+
if (output.length > 20) {
|
| 889 |
+
output.splice(0, output.length - 20);
|
| 890 |
+
}
|
| 891 |
+
const parsed = collectOutputLines(outputBuffers[stream], text);
|
| 892 |
+
outputBuffers[stream] = parsed.carry;
|
| 893 |
+
const nowMs = performance.now() - startAt;
|
| 894 |
+
for (const line of parsed.lines) {
|
| 895 |
+
onLine(line, nowMs);
|
| 896 |
+
}
|
| 897 |
+
};
|
| 898 |
+
startedChild.stdout.on("data", (chunk: Buffer) => onChunk("stdout", chunk));
|
| 899 |
+
startedChild.stderr.on("data", (chunk: Buffer) => onChunk("stderr", chunk));
|
| 900 |
+
|
| 901 |
+
const [healthz, readyz] = await Promise.all([
|
| 902 |
+
waitForProbe({
|
| 903 |
+
deadlineAt,
|
| 904 |
+
isDone: () => childExited,
|
| 905 |
+
path: "/healthz",
|
| 906 |
+
port,
|
| 907 |
+
startAt,
|
| 908 |
+
}),
|
| 909 |
+
waitForProbe({
|
| 910 |
+
deadlineAt,
|
| 911 |
+
isDone: () => childExited,
|
| 912 |
+
path: "/readyz",
|
| 913 |
+
port,
|
| 914 |
+
startAt,
|
| 915 |
+
}),
|
| 916 |
+
]);
|
| 917 |
+
const completionMs = options.benchCase.completionTracePhase
|
| 918 |
+
? await waitForStartupTracePhase({
|
| 919 |
+
deadlineAt,
|
| 920 |
+
isDone: () => childExited,
|
| 921 |
+
phase: options.benchCase.completionTracePhase,
|
| 922 |
+
startupTrace,
|
| 923 |
+
})
|
| 924 |
+
: performance.now() - startAt;
|
| 925 |
+
const completedAt = performance.now();
|
| 926 |
+
const cpuEndMs = readProcessTreeCpuMs(startedChild.pid);
|
| 927 |
+
const cpuMs =
|
| 928 |
+
cpuStartMs == null || cpuEndMs == null ? null : Math.max(0, cpuEndMs - cpuStartMs);
|
| 929 |
+
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, completedAt - startAt);
|
| 930 |
+
const exit = await stopChild(startedChild);
|
| 931 |
+
sampleRss();
|
| 932 |
+
child = undefined;
|
| 933 |
+
// stopChild is the bounded teardown wait; the raw exit promise may never settle.
|
| 934 |
+
void childExitPromise.catch(() => null);
|
| 935 |
+
flushOutputLineBuffers(outputBuffers, onLine, performance.now() - startAt, {
|
| 936 |
+
flushPartial: true,
|
| 937 |
+
});
|
| 938 |
+
|
| 939 |
+
return {
|
| 940 |
+
completionMs,
|
| 941 |
+
cpuCoreRatio,
|
| 942 |
+
cpuMs,
|
| 943 |
+
exitedBeforeTeardown: exit.exitedBeforeTeardown,
|
| 944 |
+
exitCode: exit.exitCode,
|
| 945 |
+
firstOutputMs,
|
| 946 |
+
gatewayReadyLogLine,
|
| 947 |
+
gatewayReadyLogMs,
|
| 948 |
+
healthz,
|
| 949 |
+
httpListenLogLine,
|
| 950 |
+
httpListenLogMs,
|
| 951 |
+
maxRssMb,
|
| 952 |
+
outputTail: output.join("").split(/\r?\n/u).slice(-20).join("\n"),
|
| 953 |
+
readyz,
|
| 954 |
+
signal: exit.signal,
|
| 955 |
+
startupTrace,
|
| 956 |
+
};
|
| 957 |
+
} finally {
|
| 958 |
+
if (rssTimer) {
|
| 959 |
+
clearInterval(rssTimer);
|
| 960 |
+
}
|
| 961 |
+
if (child) {
|
| 962 |
+
await stopChild(child).catch(() => undefined);
|
| 963 |
+
}
|
| 964 |
+
}
|
| 965 |
+
});
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
async function runCase(options: {
|
| 969 |
+
benchCase: GatewayBenchCase;
|
| 970 |
+
cpuProfDir?: string;
|
| 971 |
+
entry: string;
|
| 972 |
+
heapProfDir?: string;
|
| 973 |
+
runs: number;
|
| 974 |
+
timeoutMs: number;
|
| 975 |
+
warmup: number;
|
| 976 |
+
}): Promise<CaseResult> {
|
| 977 |
+
const samples: GatewaySample[] = [];
|
| 978 |
+
const total = options.runs + options.warmup;
|
| 979 |
+
for (let index = 0; index < total; index += 1) {
|
| 980 |
+
const sample = await runGatewaySample({
|
| 981 |
+
benchCase: options.benchCase,
|
| 982 |
+
cpuProfDir: options.cpuProfDir,
|
| 983 |
+
entry: options.entry,
|
| 984 |
+
heapProfDir: options.heapProfDir,
|
| 985 |
+
sampleIndex: index + 1,
|
| 986 |
+
timeoutMs: options.timeoutMs,
|
| 987 |
+
});
|
| 988 |
+
if (index >= options.warmup) {
|
| 989 |
+
samples.push(sample);
|
| 990 |
+
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
| 991 |
+
console.log(
|
| 992 |
+
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: completion=${formatMs(sample.completionMs)} healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
| 993 |
+
);
|
| 994 |
+
if (
|
| 995 |
+
sample.outputTail &&
|
| 996 |
+
(sample.completionMs == null ||
|
| 997 |
+
sample.healthz.status !== 200 ||
|
| 998 |
+
sample.readyz.status !== 200)
|
| 999 |
+
) {
|
| 1000 |
+
console.error(
|
| 1001 |
+
`[gateway-startup-bench] ${options.benchCase.id} output tail:\n${sample.outputTail}`,
|
| 1002 |
+
);
|
| 1003 |
+
}
|
| 1004 |
+
} else {
|
| 1005 |
+
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
| 1006 |
+
console.log(
|
| 1007 |
+
`[gateway-startup-bench] ${options.benchCase.id} warmup ${index + 1}/${options.warmup}: healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
| 1008 |
+
);
|
| 1009 |
+
}
|
| 1010 |
+
}
|
| 1011 |
+
return summarizeCase(options.benchCase, samples);
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
function printResult(result: CaseResult): void {
|
| 1015 |
+
console.log(`\n${result.name} (${result.id})`);
|
| 1016 |
+
console.log(` completion: ${formatStats(result.summary.completionMs)}`);
|
| 1017 |
+
console.log(` first output: ${formatStats(result.summary.firstOutputMs)}`);
|
| 1018 |
+
console.log(` CPU: ${formatStats(result.summary.cpuMs)}`);
|
| 1019 |
+
console.log(` CPU core: ${formatStats(result.summary.cpuCoreRatio, formatRatio)}`);
|
| 1020 |
+
console.log(` /healthz: ${formatStats(result.summary.healthzMs)}`);
|
| 1021 |
+
console.log(` http listen: ${formatStats(result.summary.httpListenLogMs)}`);
|
| 1022 |
+
console.log(` gateway ready: ${formatStats(result.summary.gatewayReadyLogMs)}`);
|
| 1023 |
+
console.log(` /readyz: ${formatStats(result.summary.readyzMs)}`);
|
| 1024 |
+
console.log(` max RSS: ${formatStats(result.summary.maxRssMb, formatMb)}`);
|
| 1025 |
+
console.log(
|
| 1026 |
+
` ready memory: rss=${formatStats(result.summary.startupTrace["memory.ready.rssMb"], formatMb)} heap=${formatStats(result.summary.startupTrace["memory.ready.heapUsedMb"], formatMb)} external=${formatStats(result.summary.startupTrace["memory.ready.externalMb"], formatMb)}`,
|
| 1027 |
+
);
|
| 1028 |
+
console.log(
|
| 1029 |
+
` post-ready memory: rss=${formatStats(result.summary.startupTrace["memory.post-ready.rssMb"], formatMb)} heap=${formatStats(result.summary.startupTrace["memory.post-ready.heapUsedMb"], formatMb)} external=${formatStats(result.summary.startupTrace["memory.post-ready.externalMb"], formatMb)}`,
|
| 1030 |
+
);
|
| 1031 |
+
console.log(
|
| 1032 |
+
` prepared runtime: agents=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentCount"], String)} workspaces=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceGroupCount"], String)} configuredGroups=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredFactsGroupCount"], String)} configuredModels=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredRuntimeModelCount"], String)} generatedPlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogPluginCount"], String)} generatedReads=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogReadCount"], String)} sources=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceCount"], String)} credentials=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.credentialGroupCount"], String)} catalogs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogGroupCount"], String)} registries=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimeRegistryCount"], String)} workspaceFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceFactsMs"])} runtimePlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimePluginMs"])} metadata=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.pluginMetadataMs"])} staticProviders=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.staticProviderCatalogMs"])} ambientAuth=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.ambientCredentialsMs"])} agentFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentFactsMs"])} configuredProjection=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredProjectionMs"])} sourceMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceMs"])} registryMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.registryMs"])} sourceLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.sourceConcurrencyLimitCount"], String)} fullCatalogLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.fullCatalogConcurrencyLimitCount"], String)} staticCatalog=${formatStats(result.summary.startupTrace["benchmark.preparedRuntimeStaticCatalogCallCount"], String)} pluginLoader=${formatStats(result.summary.startupTrace["sidecars.plugin-loader.callsCount"], String)} eventLoopMax=${formatStats(result.summary.startupTrace["sidecars.model-runtime.eventLoopMax"])}`,
|
| 1033 |
+
);
|
| 1034 |
+
const trace = selectSlowStartupTraceDurations(result.summary.startupTrace, 8);
|
| 1035 |
+
if (trace.length > 0) {
|
| 1036 |
+
console.log(" trace top:");
|
| 1037 |
+
for (const [name, stats] of trace) {
|
| 1038 |
+
console.log(` ${name}: ${formatStats(stats)}`);
|
| 1039 |
+
}
|
| 1040 |
+
}
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
async function main() {
|
| 1044 |
+
const argv = process.argv.slice(2);
|
| 1045 |
+
if (hasHelpFlag(argv)) {
|
| 1046 |
+
printUsage();
|
| 1047 |
+
return;
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
const options = parseOptions(argv);
|
| 1051 |
+
if (options.cpuProfDir) {
|
| 1052 |
+
mkdirSync(options.cpuProfDir, { recursive: true });
|
| 1053 |
+
}
|
| 1054 |
+
if (options.heapProfDir) {
|
| 1055 |
+
mkdirSync(options.heapProfDir, { recursive: true });
|
| 1056 |
+
}
|
| 1057 |
+
const results: CaseResult[] = [];
|
| 1058 |
+
for (const benchCase of options.cases) {
|
| 1059 |
+
results.push(
|
| 1060 |
+
await runCase({
|
| 1061 |
+
benchCase,
|
| 1062 |
+
cpuProfDir: options.cpuProfDir,
|
| 1063 |
+
entry: options.entry,
|
| 1064 |
+
heapProfDir: options.heapProfDir,
|
| 1065 |
+
runs: options.runs,
|
| 1066 |
+
timeoutMs: options.timeoutMs,
|
| 1067 |
+
warmup: options.warmup,
|
| 1068 |
+
}),
|
| 1069 |
+
);
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
const payload = {
|
| 1073 |
+
entry: options.entry,
|
| 1074 |
+
generatedAt: new Date().toISOString(),
|
| 1075 |
+
results,
|
| 1076 |
+
};
|
| 1077 |
+
if (options.output) {
|
| 1078 |
+
mkdirSync(path.dirname(options.output), { recursive: true });
|
| 1079 |
+
writeFileSync(options.output, `${JSON.stringify(payload, null, 2)}\n`);
|
| 1080 |
+
}
|
| 1081 |
+
if (options.json) {
|
| 1082 |
+
console.log(JSON.stringify(payload, null, 2));
|
| 1083 |
+
} else {
|
| 1084 |
+
for (const result of results) {
|
| 1085 |
+
printResult(result);
|
| 1086 |
+
}
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
const failures = collectResultFailures(results);
|
| 1090 |
+
if (failures.length > 0) {
|
| 1091 |
+
printBenchmarkFailures(failures);
|
| 1092 |
+
process.exitCode = 1;
|
| 1093 |
+
}
|
| 1094 |
+
}
|
| 1095 |
+
|
| 1096 |
+
export const testing = {
|
| 1097 |
+
collectResultFailures,
|
| 1098 |
+
collectStartupTrace,
|
| 1099 |
+
listIncidentPackagedPluginArtifacts,
|
| 1100 |
+
parseOptions,
|
| 1101 |
+
sanitizedEnv,
|
| 1102 |
+
summarizeCase,
|
| 1103 |
+
waitForStartupTracePhase,
|
| 1104 |
+
withGatewayBenchRoot,
|
| 1105 |
+
writeIncidentFixture,
|
| 1106 |
+
writeConfig,
|
| 1107 |
+
};
|
| 1108 |
+
|
| 1109 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 1110 |
+
main().catch((err: unknown) => {
|
| 1111 |
+
if (err instanceof CliArgumentError) {
|
| 1112 |
+
console.error(err.message);
|
| 1113 |
+
process.exitCode = 1;
|
| 1114 |
+
return;
|
| 1115 |
+
}
|
| 1116 |
+
console.error(err instanceof Error ? err.stack : String(err));
|
| 1117 |
+
process.exitCode = 1;
|
| 1118 |
+
});
|
| 1119 |
+
}
|
scripts/bench-task-registry-sqlite-worker.ts
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from "node:path";
|
| 2 |
+
import { performance } from "node:perf_hooks";
|
| 3 |
+
import { pathToFileURL } from "node:url";
|
| 4 |
+
import { toErrorObject } from "@openclaw/normalization-core/error-coercion";
|
| 5 |
+
import type { DB as OpenClawStateKyselyDatabase } from "../src/state/openclaw-state-db.generated.js";
|
| 6 |
+
import {
|
| 7 |
+
WORKER_RESULT_SENTINEL,
|
| 8 |
+
type MemorySample,
|
| 9 |
+
type RegistryLifecycleCounts,
|
| 10 |
+
type RegistrySnapshot,
|
| 11 |
+
type RetainedMemoryMetrics,
|
| 12 |
+
type WorkerResult,
|
| 13 |
+
} from "./bench-task-registry-sqlite.js";
|
| 14 |
+
import { classifyBoundedUnsignedDecimal } from "./lib/arg-utils.mts";
|
| 15 |
+
|
| 16 |
+
type WorkerOptions = {
|
| 17 |
+
size: number;
|
| 18 |
+
cycles: number;
|
| 19 |
+
warmup: number;
|
| 20 |
+
stateDir: string;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
type TimingSample = {
|
| 24 |
+
registrationMs: number;
|
| 25 |
+
terminalMs: number;
|
| 26 |
+
teardownMs: number;
|
| 27 |
+
registration: RegistrySnapshot;
|
| 28 |
+
terminal: RegistrySnapshot;
|
| 29 |
+
teardown: RegistrySnapshot;
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
type BenchmarkStateDatabase = Pick<
|
| 33 |
+
OpenClawStateKyselyDatabase,
|
| 34 |
+
"task_delivery_state" | "task_runs"
|
| 35 |
+
>;
|
| 36 |
+
|
| 37 |
+
type TaskRecordApi = Pick<
|
| 38 |
+
typeof import("../src/tasks/task-registry-record-api.js"),
|
| 39 |
+
"createTaskRecord" | "markTaskTerminalById"
|
| 40 |
+
>;
|
| 41 |
+
|
| 42 |
+
type TaskRegistryQueryApi = Pick<
|
| 43 |
+
typeof import("../src/tasks/task-registry-query.js"),
|
| 44 |
+
"deleteTaskRecordById"
|
| 45 |
+
>;
|
| 46 |
+
|
| 47 |
+
function parseInteger(raw: string | undefined, flag: string, min: number, max: number): number {
|
| 48 |
+
const result = classifyBoundedUnsignedDecimal(raw, min, max);
|
| 49 |
+
if (result.kind === "syntax") {
|
| 50 |
+
throw new Error(`${flag} must be an integer`);
|
| 51 |
+
}
|
| 52 |
+
if (result.kind !== "value") {
|
| 53 |
+
throw new Error(`${flag} must be between ${min} and ${max}`);
|
| 54 |
+
}
|
| 55 |
+
return result.value;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
function parseOptions(argv: string[]): WorkerOptions {
|
| 59 |
+
const values = new Map<string, string>();
|
| 60 |
+
for (let index = 0; index < argv.length; index += 2) {
|
| 61 |
+
const flag = argv[index];
|
| 62 |
+
const value = argv[index + 1];
|
| 63 |
+
if (!flag?.startsWith("--") || !value || value.startsWith("--")) {
|
| 64 |
+
throw new Error(`invalid worker argument near ${flag ?? "end"}`);
|
| 65 |
+
}
|
| 66 |
+
if (values.has(flag)) {
|
| 67 |
+
throw new Error(`${flag} was provided more than once`);
|
| 68 |
+
}
|
| 69 |
+
values.set(flag, value);
|
| 70 |
+
}
|
| 71 |
+
const stateDir = values.get("--state-dir");
|
| 72 |
+
if (!stateDir || !path.isAbsolute(stateDir)) {
|
| 73 |
+
throw new Error("--state-dir must be an absolute path");
|
| 74 |
+
}
|
| 75 |
+
return {
|
| 76 |
+
size: parseInteger(values.get("--size"), "--size", 1, 4096),
|
| 77 |
+
cycles: parseInteger(values.get("--cycles"), "--cycles", 1, 200),
|
| 78 |
+
warmup: parseInteger(values.get("--warmup"), "--warmup", 0, 20),
|
| 79 |
+
stateDir,
|
| 80 |
+
};
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function processPeakRssBytes(): number {
|
| 84 |
+
return Math.max(0, Math.round(process.resourceUsage().maxRSS * 1024));
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
function forceGc(): void {
|
| 88 |
+
const gc = (globalThis as typeof globalThis & { gc?: () => void }).gc;
|
| 89 |
+
if (!gc) {
|
| 90 |
+
throw new Error("benchmark worker requires --expose-gc");
|
| 91 |
+
}
|
| 92 |
+
gc();
|
| 93 |
+
gc();
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
function readMemorySample(cycle: number): MemorySample {
|
| 97 |
+
const memory = process.memoryUsage();
|
| 98 |
+
return {
|
| 99 |
+
cycle,
|
| 100 |
+
heapUsedBytes: memory.heapUsed,
|
| 101 |
+
heapTotalBytes: memory.heapTotal,
|
| 102 |
+
rssBytes: memory.rss,
|
| 103 |
+
externalBytes: memory.external,
|
| 104 |
+
arrayBuffersBytes: memory.arrayBuffers,
|
| 105 |
+
processPeakRssBytes: processPeakRssBytes(),
|
| 106 |
+
};
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function slope(values: number[]): number {
|
| 110 |
+
if (values.length < 2) {
|
| 111 |
+
return 0;
|
| 112 |
+
}
|
| 113 |
+
const xMean = (values.length - 1) / 2;
|
| 114 |
+
const yMean = values.reduce((sum, value) => sum + value, 0) / values.length;
|
| 115 |
+
let numerator = 0;
|
| 116 |
+
let denominator = 0;
|
| 117 |
+
for (let index = 0; index < values.length; index += 1) {
|
| 118 |
+
const xDelta = index - xMean;
|
| 119 |
+
numerator += xDelta * ((values[index] ?? 0) - yMean);
|
| 120 |
+
denominator += xDelta * xDelta;
|
| 121 |
+
}
|
| 122 |
+
return denominator === 0 ? 0 : numerator / denominator;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function retainedMemorySlopes(samples: MemorySample[]): RetainedMemoryMetrics {
|
| 126 |
+
return {
|
| 127 |
+
heapUsedBytes: slope(samples.map((sample) => sample.heapUsedBytes)),
|
| 128 |
+
heapTotalBytes: slope(samples.map((sample) => sample.heapTotalBytes)),
|
| 129 |
+
rssBytes: slope(samples.map((sample) => sample.rssBytes)),
|
| 130 |
+
externalBytes: slope(samples.map((sample) => sample.externalBytes)),
|
| 131 |
+
arrayBuffersBytes: slope(samples.map((sample) => sample.arrayBuffersBytes)),
|
| 132 |
+
};
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
function retainedMemoryDelta(
|
| 136 |
+
baseline: MemorySample,
|
| 137 |
+
finalSample: MemorySample,
|
| 138 |
+
): RetainedMemoryMetrics {
|
| 139 |
+
return {
|
| 140 |
+
heapUsedBytes: finalSample.heapUsedBytes - baseline.heapUsedBytes,
|
| 141 |
+
heapTotalBytes: finalSample.heapTotalBytes - baseline.heapTotalBytes,
|
| 142 |
+
rssBytes: finalSample.rssBytes - baseline.rssBytes,
|
| 143 |
+
externalBytes: finalSample.externalBytes - baseline.externalBytes,
|
| 144 |
+
arrayBuffersBytes: finalSample.arrayBuffersBytes - baseline.arrayBuffersBytes,
|
| 145 |
+
};
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
function assertLifecycleCounts(
|
| 149 |
+
actual: RegistryLifecycleCounts,
|
| 150 |
+
expected: RegistryLifecycleCounts,
|
| 151 |
+
phase: string,
|
| 152 |
+
surface: string,
|
| 153 |
+
): void {
|
| 154 |
+
for (const field of Object.keys(expected) as Array<keyof RegistryLifecycleCounts>) {
|
| 155 |
+
if (actual[field] !== expected[field]) {
|
| 156 |
+
throw new Error(
|
| 157 |
+
`${phase} ${surface} invariant failed: ${JSON.stringify({ expected, actual })}`,
|
| 158 |
+
);
|
| 159 |
+
}
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
function assertSnapshot(
|
| 164 |
+
actual: RegistrySnapshot,
|
| 165 |
+
expected: RegistryLifecycleCounts,
|
| 166 |
+
phase: string,
|
| 167 |
+
): void {
|
| 168 |
+
for (const surface of ["memory", "sqlite"] as const) {
|
| 169 |
+
assertLifecycleCounts(actual[surface], expected, phase, surface);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
async function createCountReader() {
|
| 174 |
+
const [{ executeSqliteQuerySync, getNodeSqliteKysely }, stateDb, state] = await Promise.all([
|
| 175 |
+
import("../src/infra/kysely-sync.js"),
|
| 176 |
+
import("../src/state/openclaw-state-db.js"),
|
| 177 |
+
import("../src/tasks/task-registry-state.js"),
|
| 178 |
+
]);
|
| 179 |
+
return (): RegistrySnapshot => {
|
| 180 |
+
const database = stateDb.openOpenClawStateDatabase();
|
| 181 |
+
const db = getNodeSqliteKysely<BenchmarkStateDatabase>(database.db);
|
| 182 |
+
const taskRows = executeSqliteQuerySync(
|
| 183 |
+
database.db,
|
| 184 |
+
db.selectFrom("task_runs").select(["status", "delivery_status", "terminal_outcome"]),
|
| 185 |
+
).rows;
|
| 186 |
+
const deliveryRows = executeSqliteQuerySync(
|
| 187 |
+
database.db,
|
| 188 |
+
db.selectFrom("task_delivery_state").select(({ fn }) => fn.countAll<number>().as("count")),
|
| 189 |
+
).rows[0]?.count;
|
| 190 |
+
const summarize = (
|
| 191 |
+
taskRecords: Iterable<{
|
| 192 |
+
status: string;
|
| 193 |
+
deliveryStatus: string;
|
| 194 |
+
terminalOutcome?: string | null;
|
| 195 |
+
}>,
|
| 196 |
+
deliveryStateCount: number,
|
| 197 |
+
): RegistryLifecycleCounts => {
|
| 198 |
+
let taskCount = 0;
|
| 199 |
+
let runningTasks = 0;
|
| 200 |
+
let succeededTasks = 0;
|
| 201 |
+
let pendingDeliveryTasks = 0;
|
| 202 |
+
let succeededTerminalOutcomes = 0;
|
| 203 |
+
for (const task of taskRecords) {
|
| 204 |
+
taskCount += 1;
|
| 205 |
+
runningTasks += task.status === "running" ? 1 : 0;
|
| 206 |
+
succeededTasks += task.status === "succeeded" ? 1 : 0;
|
| 207 |
+
pendingDeliveryTasks += task.deliveryStatus === "pending" ? 1 : 0;
|
| 208 |
+
succeededTerminalOutcomes += task.terminalOutcome === "succeeded" ? 1 : 0;
|
| 209 |
+
}
|
| 210 |
+
return {
|
| 211 |
+
taskCount,
|
| 212 |
+
deliveryStateCount,
|
| 213 |
+
runningTasks,
|
| 214 |
+
succeededTasks,
|
| 215 |
+
pendingDeliveryTasks,
|
| 216 |
+
succeededTerminalOutcomes,
|
| 217 |
+
};
|
| 218 |
+
};
|
| 219 |
+
return {
|
| 220 |
+
memory: summarize(
|
| 221 |
+
[...state.tasks.values()].map((task) => ({
|
| 222 |
+
status: task.status,
|
| 223 |
+
deliveryStatus: task.deliveryStatus,
|
| 224 |
+
terminalOutcome: task.terminalOutcome,
|
| 225 |
+
})),
|
| 226 |
+
state.taskDeliveryStates.size,
|
| 227 |
+
),
|
| 228 |
+
sqlite: summarize(
|
| 229 |
+
taskRows.map((task) => ({
|
| 230 |
+
status: task.status,
|
| 231 |
+
deliveryStatus: task.delivery_status,
|
| 232 |
+
terminalOutcome: task.terminal_outcome,
|
| 233 |
+
})),
|
| 234 |
+
deliveryRows ?? 0,
|
| 235 |
+
),
|
| 236 |
+
};
|
| 237 |
+
};
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
async function runCycle(
|
| 241 |
+
size: number,
|
| 242 |
+
serial: number,
|
| 243 |
+
readSnapshot: () => RegistrySnapshot,
|
| 244 |
+
taskRecordApi: TaskRecordApi,
|
| 245 |
+
taskRegistryQuery: TaskRegistryQueryApi,
|
| 246 |
+
): Promise<TimingSample> {
|
| 247 |
+
const taskIds: string[] = [];
|
| 248 |
+
const startedAt = Date.now();
|
| 249 |
+
const registrationStartedAt = performance.now();
|
| 250 |
+
for (let index = 0; index < size; index += 1) {
|
| 251 |
+
const task = taskRecordApi.createTaskRecord({
|
| 252 |
+
runtime: "subagent",
|
| 253 |
+
requesterSessionKey: "agent:benchmark:main",
|
| 254 |
+
ownerKey: "agent:benchmark:main",
|
| 255 |
+
scopeKind: "session",
|
| 256 |
+
requesterOrigin: { channel: "benchmark", to: "task-registry" },
|
| 257 |
+
childSessionKey: `agent:benchmark:subagent:${serial}:${index}`,
|
| 258 |
+
runId: `benchmark-task-${serial}-${index}`,
|
| 259 |
+
task: `durable registration benchmark ${index}`,
|
| 260 |
+
status: "running",
|
| 261 |
+
deliveryStatus: "pending",
|
| 262 |
+
notifyPolicy: "silent",
|
| 263 |
+
startedAt,
|
| 264 |
+
lastEventAt: startedAt,
|
| 265 |
+
});
|
| 266 |
+
if (!task) {
|
| 267 |
+
throw new Error(`task registration failed at ${index + 1}/${size}`);
|
| 268 |
+
}
|
| 269 |
+
taskIds.push(task.taskId);
|
| 270 |
+
}
|
| 271 |
+
const registrationMs = performance.now() - registrationStartedAt;
|
| 272 |
+
const emptyCounts: RegistryLifecycleCounts = {
|
| 273 |
+
taskCount: 0,
|
| 274 |
+
deliveryStateCount: 0,
|
| 275 |
+
runningTasks: 0,
|
| 276 |
+
succeededTasks: 0,
|
| 277 |
+
pendingDeliveryTasks: 0,
|
| 278 |
+
succeededTerminalOutcomes: 0,
|
| 279 |
+
};
|
| 280 |
+
const registration = readSnapshot();
|
| 281 |
+
assertSnapshot(
|
| 282 |
+
registration,
|
| 283 |
+
{
|
| 284 |
+
...emptyCounts,
|
| 285 |
+
taskCount: size,
|
| 286 |
+
deliveryStateCount: size,
|
| 287 |
+
runningTasks: size,
|
| 288 |
+
pendingDeliveryTasks: size,
|
| 289 |
+
},
|
| 290 |
+
"registration",
|
| 291 |
+
);
|
| 292 |
+
|
| 293 |
+
const terminalStartedAt = performance.now();
|
| 294 |
+
for (const taskId of taskIds) {
|
| 295 |
+
const endedAt = Date.now();
|
| 296 |
+
const task = taskRecordApi.markTaskTerminalById({
|
| 297 |
+
taskId,
|
| 298 |
+
status: "succeeded",
|
| 299 |
+
endedAt,
|
| 300 |
+
lastEventAt: endedAt,
|
| 301 |
+
terminalOutcome: "succeeded",
|
| 302 |
+
});
|
| 303 |
+
if (!task) {
|
| 304 |
+
throw new Error(`terminal transition failed for task ${taskId}`);
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
const terminalMs = performance.now() - terminalStartedAt;
|
| 308 |
+
const terminal = readSnapshot();
|
| 309 |
+
assertSnapshot(
|
| 310 |
+
terminal,
|
| 311 |
+
{
|
| 312 |
+
...emptyCounts,
|
| 313 |
+
taskCount: size,
|
| 314 |
+
deliveryStateCount: size,
|
| 315 |
+
succeededTasks: size,
|
| 316 |
+
pendingDeliveryTasks: size,
|
| 317 |
+
succeededTerminalOutcomes: size,
|
| 318 |
+
},
|
| 319 |
+
"terminal",
|
| 320 |
+
);
|
| 321 |
+
|
| 322 |
+
const teardownStartedAt = performance.now();
|
| 323 |
+
for (const taskId of taskIds) {
|
| 324 |
+
if (!taskRegistryQuery.deleteTaskRecordById(taskId)) {
|
| 325 |
+
throw new Error(`teardown failed for task ${taskId}`);
|
| 326 |
+
}
|
| 327 |
+
}
|
| 328 |
+
const teardownMs = performance.now() - teardownStartedAt;
|
| 329 |
+
const teardown = readSnapshot();
|
| 330 |
+
assertSnapshot(teardown, emptyCounts, "teardown");
|
| 331 |
+
return { registrationMs, terminalMs, teardownMs, registration, terminal, teardown };
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
async function resetRuntime(persist: boolean): Promise<void> {
|
| 335 |
+
const [tasks, stateDb] = await Promise.all([
|
| 336 |
+
import("../src/tasks/task-runtime.test-helpers.js"),
|
| 337 |
+
import("../src/state/openclaw-state-db.js"),
|
| 338 |
+
]);
|
| 339 |
+
tasks.resetTaskRegistryForTests({ persist });
|
| 340 |
+
stateDb.closeOpenClawStateDatabaseForTest();
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
async function runBenchmark(options: WorkerOptions): Promise<WorkerResult> {
|
| 344 |
+
await resetRuntime(true);
|
| 345 |
+
// Load lifecycle owners before the baseline so warmup=0 still measures task churn,
|
| 346 |
+
// not one-time module initialization retained by the worker.
|
| 347 |
+
const [readSnapshot, taskRecordApi, taskRegistryQuery] = await Promise.all([
|
| 348 |
+
createCountReader(),
|
| 349 |
+
import("../src/tasks/task-registry-record-api.js"),
|
| 350 |
+
import("../src/tasks/task-registry-query.js"),
|
| 351 |
+
]);
|
| 352 |
+
const emptyCounts: RegistryLifecycleCounts = {
|
| 353 |
+
taskCount: 0,
|
| 354 |
+
deliveryStateCount: 0,
|
| 355 |
+
runningTasks: 0,
|
| 356 |
+
succeededTasks: 0,
|
| 357 |
+
pendingDeliveryTasks: 0,
|
| 358 |
+
succeededTerminalOutcomes: 0,
|
| 359 |
+
};
|
| 360 |
+
assertSnapshot(readSnapshot(), emptyCounts, "initial");
|
| 361 |
+
const timingsMs = {
|
| 362 |
+
registration: [] as number[],
|
| 363 |
+
terminal: [] as number[],
|
| 364 |
+
teardown: [] as number[],
|
| 365 |
+
};
|
| 366 |
+
const postGcSamples: MemorySample[] = [];
|
| 367 |
+
let lastSample: TimingSample | undefined;
|
| 368 |
+
for (let index = 0; index < options.warmup; index += 1) {
|
| 369 |
+
await runCycle(options.size, index, readSnapshot, taskRecordApi, taskRegistryQuery);
|
| 370 |
+
forceGc();
|
| 371 |
+
}
|
| 372 |
+
forceGc();
|
| 373 |
+
const postGcBaseline = readMemorySample(-1);
|
| 374 |
+
for (let index = 0; index < options.cycles; index += 1) {
|
| 375 |
+
lastSample = await runCycle(
|
| 376 |
+
options.size,
|
| 377 |
+
options.warmup + index,
|
| 378 |
+
readSnapshot,
|
| 379 |
+
taskRecordApi,
|
| 380 |
+
taskRegistryQuery,
|
| 381 |
+
);
|
| 382 |
+
forceGc();
|
| 383 |
+
timingsMs.registration.push(lastSample.registrationMs);
|
| 384 |
+
timingsMs.terminal.push(lastSample.terminalMs);
|
| 385 |
+
timingsMs.teardown.push(lastSample.teardownMs);
|
| 386 |
+
postGcSamples.push(readMemorySample(index));
|
| 387 |
+
}
|
| 388 |
+
if (!lastSample) {
|
| 389 |
+
throw new Error("benchmark completed without a cycle");
|
| 390 |
+
}
|
| 391 |
+
const finalPostGcSample = postGcSamples.at(-1);
|
| 392 |
+
if (!finalPostGcSample) {
|
| 393 |
+
throw new Error("benchmark completed without a post-GC sample");
|
| 394 |
+
}
|
| 395 |
+
return {
|
| 396 |
+
size: options.size,
|
| 397 |
+
timingsMs,
|
| 398 |
+
memory: {
|
| 399 |
+
postGcBaseline,
|
| 400 |
+
postGcSamples,
|
| 401 |
+
retainedSlopesBytesPerCycle: retainedMemorySlopes(postGcSamples),
|
| 402 |
+
retainedDeltasBytes: retainedMemoryDelta(postGcBaseline, finalPostGcSample),
|
| 403 |
+
processPeakRssBytes: processPeakRssBytes(),
|
| 404 |
+
},
|
| 405 |
+
invariant: {
|
| 406 |
+
ok: true,
|
| 407 |
+
cyclesValidated: options.warmup + options.cycles,
|
| 408 |
+
registration: lastSample.registration,
|
| 409 |
+
terminal: lastSample.terminal,
|
| 410 |
+
teardown: lastSample.teardown,
|
| 411 |
+
serializedSharedConnection: true,
|
| 412 |
+
},
|
| 413 |
+
};
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
async function main(): Promise<void> {
|
| 417 |
+
const options = parseOptions(process.argv.slice(2));
|
| 418 |
+
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
| 419 |
+
const previousNodeEnv = process.env.NODE_ENV;
|
| 420 |
+
let result: WorkerResult | undefined;
|
| 421 |
+
let failure: unknown;
|
| 422 |
+
process.env.OPENCLAW_STATE_DIR = options.stateDir;
|
| 423 |
+
process.env.NODE_ENV = "test";
|
| 424 |
+
try {
|
| 425 |
+
const { pinRuntimePaths } = await import("../src/config/paths.js");
|
| 426 |
+
pinRuntimePaths();
|
| 427 |
+
result = await runBenchmark(options);
|
| 428 |
+
} catch (error) {
|
| 429 |
+
failure = error;
|
| 430 |
+
} finally {
|
| 431 |
+
try {
|
| 432 |
+
await resetRuntime(false);
|
| 433 |
+
} catch (error) {
|
| 434 |
+
failure ??= error;
|
| 435 |
+
} finally {
|
| 436 |
+
if (previousStateDir === undefined) {
|
| 437 |
+
delete process.env.OPENCLAW_STATE_DIR;
|
| 438 |
+
} else {
|
| 439 |
+
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
| 440 |
+
}
|
| 441 |
+
if (previousNodeEnv === undefined) {
|
| 442 |
+
delete process.env.NODE_ENV;
|
| 443 |
+
} else {
|
| 444 |
+
process.env.NODE_ENV = previousNodeEnv;
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
}
|
| 448 |
+
if (failure) {
|
| 449 |
+
throw toErrorObject(failure, "Task registry SQLite benchmark failed");
|
| 450 |
+
}
|
| 451 |
+
if (!result) {
|
| 452 |
+
throw new Error("benchmark worker completed without a result");
|
| 453 |
+
}
|
| 454 |
+
process.stdout.write(`${WORKER_RESULT_SENTINEL}${JSON.stringify(result)}\n`);
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 458 |
+
try {
|
| 459 |
+
await main();
|
| 460 |
+
} catch (error) {
|
| 461 |
+
console.error(error instanceof Error ? error.stack : String(error));
|
| 462 |
+
process.exitCode = 1;
|
| 463 |
+
} finally {
|
| 464 |
+
if (process.exitCode && process.exitCode !== 0) {
|
| 465 |
+
console.error(`[bench-task-registry-sqlite-worker] FAILED (exit ${process.exitCode})`);
|
| 466 |
+
}
|
| 467 |
+
}
|
| 468 |
+
}
|
scripts/bench-task-registry-sqlite.ts
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs";
|
| 2 |
+
import os from "node:os";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { pathToFileURL } from "node:url";
|
| 5 |
+
import { isRecord } from "@openclaw/normalization-core/record-coerce";
|
| 6 |
+
import {
|
| 7 |
+
emitBenchmarkReport,
|
| 8 |
+
parseBenchmarkInteger,
|
| 9 |
+
parseBenchmarkIntegerList,
|
| 10 |
+
parseBenchmarkOptions,
|
| 11 |
+
parseBenchmarkWorkerResult,
|
| 12 |
+
runBenchmarkEntrypoint,
|
| 13 |
+
runBenchmarkJobs,
|
| 14 |
+
runBenchmarkWorker,
|
| 15 |
+
summarizeBenchmarkTimings,
|
| 16 |
+
type BenchmarkWorkerProcessResult,
|
| 17 |
+
type BenchmarkWorkerSpawner,
|
| 18 |
+
} from "./lib/benchmark-harness.mts";
|
| 19 |
+
|
| 20 |
+
const DEFAULT_SIZES = [24, 64, 128];
|
| 21 |
+
const WORKER_TIMEOUT_MS = 300_000;
|
| 22 |
+
export const WORKER_RESULT_SENTINEL = "[bench-task-registry-sqlite-result] ";
|
| 23 |
+
|
| 24 |
+
export type MemorySample = {
|
| 25 |
+
cycle: number;
|
| 26 |
+
heapUsedBytes: number;
|
| 27 |
+
heapTotalBytes: number;
|
| 28 |
+
rssBytes: number;
|
| 29 |
+
externalBytes: number;
|
| 30 |
+
arrayBuffersBytes: number;
|
| 31 |
+
processPeakRssBytes: number;
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
export type RetainedMemoryMetrics = Pick<
|
| 35 |
+
MemorySample,
|
| 36 |
+
"heapUsedBytes" | "heapTotalBytes" | "rssBytes" | "externalBytes" | "arrayBuffersBytes"
|
| 37 |
+
>;
|
| 38 |
+
|
| 39 |
+
export type RegistryLifecycleCounts = {
|
| 40 |
+
taskCount: number;
|
| 41 |
+
deliveryStateCount: number;
|
| 42 |
+
runningTasks: number;
|
| 43 |
+
succeededTasks: number;
|
| 44 |
+
pendingDeliveryTasks: number;
|
| 45 |
+
succeededTerminalOutcomes: number;
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
export type RegistrySnapshot = {
|
| 49 |
+
memory: RegistryLifecycleCounts;
|
| 50 |
+
sqlite: RegistryLifecycleCounts;
|
| 51 |
+
};
|
| 52 |
+
|
| 53 |
+
export type WorkerResult = {
|
| 54 |
+
size: number;
|
| 55 |
+
timingsMs: {
|
| 56 |
+
registration: number[];
|
| 57 |
+
terminal: number[];
|
| 58 |
+
teardown: number[];
|
| 59 |
+
};
|
| 60 |
+
memory: {
|
| 61 |
+
postGcBaseline: MemorySample;
|
| 62 |
+
postGcSamples: MemorySample[];
|
| 63 |
+
retainedSlopesBytesPerCycle: RetainedMemoryMetrics;
|
| 64 |
+
retainedDeltasBytes: RetainedMemoryMetrics;
|
| 65 |
+
processPeakRssBytes: number;
|
| 66 |
+
};
|
| 67 |
+
invariant: {
|
| 68 |
+
ok: boolean;
|
| 69 |
+
cyclesValidated: number;
|
| 70 |
+
registration: RegistrySnapshot;
|
| 71 |
+
terminal: RegistrySnapshot;
|
| 72 |
+
teardown: RegistrySnapshot;
|
| 73 |
+
serializedSharedConnection: boolean;
|
| 74 |
+
};
|
| 75 |
+
};
|
| 76 |
+
|
| 77 |
+
type Options = {
|
| 78 |
+
sizes: number[];
|
| 79 |
+
cycles: number;
|
| 80 |
+
warmup: number;
|
| 81 |
+
output?: string;
|
| 82 |
+
json: boolean;
|
| 83 |
+
help: boolean;
|
| 84 |
+
};
|
| 85 |
+
|
| 86 |
+
type WorkerLaunchRuntime = {
|
| 87 |
+
spawnWorker?: BenchmarkWorkerSpawner;
|
| 88 |
+
};
|
| 89 |
+
|
| 90 |
+
function usage(): string {
|
| 91 |
+
return `OpenClaw durable task registry churn benchmark
|
| 92 |
+
|
| 93 |
+
Usage:
|
| 94 |
+
node --import tsx scripts/bench-task-registry-sqlite.ts [options]
|
| 95 |
+
|
| 96 |
+
Options:
|
| 97 |
+
--sizes <list> Comma-separated subagent task-record registration burst sizes (default: 24,64,128)
|
| 98 |
+
--cycles <n> Measured create/terminal/delete cycles per size (default: 20)
|
| 99 |
+
--warmup <n> Warmup cycles per size (default: 3)
|
| 100 |
+
--output <path> Write the JSON report to a file
|
| 101 |
+
--json Print only the JSON report
|
| 102 |
+
--help Show this text
|
| 103 |
+
`;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
function parseOptions(argv: string[]): Options {
|
| 107 |
+
return parseBenchmarkOptions<Options>(
|
| 108 |
+
argv,
|
| 109 |
+
{
|
| 110 |
+
sizes: DEFAULT_SIZES,
|
| 111 |
+
cycles: 20,
|
| 112 |
+
warmup: 3,
|
| 113 |
+
json: false,
|
| 114 |
+
help: false,
|
| 115 |
+
},
|
| 116 |
+
{
|
| 117 |
+
"--sizes": (options, value) => {
|
| 118 |
+
options.sizes = parseBenchmarkIntegerList(value, "--sizes", 4096);
|
| 119 |
+
},
|
| 120 |
+
"--cycles": (options, value) => {
|
| 121 |
+
options.cycles = parseBenchmarkInteger(value, "--cycles", 1, 200);
|
| 122 |
+
},
|
| 123 |
+
"--warmup": (options, value) => {
|
| 124 |
+
options.warmup = parseBenchmarkInteger(value, "--warmup", 0, 20);
|
| 125 |
+
},
|
| 126 |
+
"--output": (options, value) => {
|
| 127 |
+
options.output = value;
|
| 128 |
+
},
|
| 129 |
+
},
|
| 130 |
+
);
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
function assertFinite(value: unknown, field: string): asserts value is number {
|
| 134 |
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
| 135 |
+
throw new Error(`worker result field ${field} must be finite`);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
function assertFiniteNonNegative(value: unknown, field: string): asserts value is number {
|
| 140 |
+
assertFinite(value, field);
|
| 141 |
+
if (value < 0) {
|
| 142 |
+
throw new Error(`worker result field ${field} must be nonnegative`);
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
const MEMORY_FIELDS = [
|
| 147 |
+
"heapUsedBytes",
|
| 148 |
+
"heapTotalBytes",
|
| 149 |
+
"rssBytes",
|
| 150 |
+
"externalBytes",
|
| 151 |
+
"arrayBuffersBytes",
|
| 152 |
+
"processPeakRssBytes",
|
| 153 |
+
] as const;
|
| 154 |
+
|
| 155 |
+
const RETAINED_MEMORY_FIELDS = [
|
| 156 |
+
"heapUsedBytes",
|
| 157 |
+
"heapTotalBytes",
|
| 158 |
+
"rssBytes",
|
| 159 |
+
"externalBytes",
|
| 160 |
+
"arrayBuffersBytes",
|
| 161 |
+
] as const;
|
| 162 |
+
|
| 163 |
+
const LIFECYCLE_COUNT_FIELDS = [
|
| 164 |
+
"taskCount",
|
| 165 |
+
"deliveryStateCount",
|
| 166 |
+
"runningTasks",
|
| 167 |
+
"succeededTasks",
|
| 168 |
+
"pendingDeliveryTasks",
|
| 169 |
+
"succeededTerminalOutcomes",
|
| 170 |
+
] as const;
|
| 171 |
+
|
| 172 |
+
function validateMemorySample(
|
| 173 |
+
value: unknown,
|
| 174 |
+
expectedCycle: number,
|
| 175 |
+
field: string,
|
| 176 |
+
): asserts value is MemorySample {
|
| 177 |
+
if (!isRecord(value) || value.cycle !== expectedCycle) {
|
| 178 |
+
throw new Error(`worker result ${field} has an invalid cycle`);
|
| 179 |
+
}
|
| 180 |
+
for (const memoryField of MEMORY_FIELDS) {
|
| 181 |
+
assertFiniteNonNegative(value[memoryField], `${field}.${memoryField}`);
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
function validateRegistrySnapshot(
|
| 186 |
+
value: unknown,
|
| 187 |
+
field: string,
|
| 188 |
+
expected: RegistryLifecycleCounts,
|
| 189 |
+
): asserts value is RegistrySnapshot {
|
| 190 |
+
if (!isRecord(value) || !isRecord(value.memory) || !isRecord(value.sqlite)) {
|
| 191 |
+
throw new Error(`worker result field ${field} must be a registry snapshot`);
|
| 192 |
+
}
|
| 193 |
+
for (const surface of ["memory", "sqlite"] as const) {
|
| 194 |
+
const counts = surface === "memory" ? value.memory : value.sqlite;
|
| 195 |
+
for (const countField of LIFECYCLE_COUNT_FIELDS) {
|
| 196 |
+
const count = counts[countField];
|
| 197 |
+
assertFiniteNonNegative(count, `${field}.${surface}.${countField}`);
|
| 198 |
+
if (count !== expected[countField]) {
|
| 199 |
+
throw new Error(`worker result field ${field}.${surface}.${countField} was unexpected`);
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function validateWorkerResult(
|
| 206 |
+
value: unknown,
|
| 207 |
+
expected: { size: number; cycles: number; warmup: number },
|
| 208 |
+
): WorkerResult {
|
| 209 |
+
if (!isRecord(value)) {
|
| 210 |
+
throw new Error("worker result must be an object");
|
| 211 |
+
}
|
| 212 |
+
if (value.size !== expected.size) {
|
| 213 |
+
throw new Error(`worker size ${expected.size} returned mismatched identity`);
|
| 214 |
+
}
|
| 215 |
+
if (!isRecord(value.timingsMs)) {
|
| 216 |
+
throw new Error("worker result timingsMs must be an object");
|
| 217 |
+
}
|
| 218 |
+
for (const phase of ["registration", "terminal", "teardown"] as const) {
|
| 219 |
+
const timings = value.timingsMs[phase];
|
| 220 |
+
if (!Array.isArray(timings) || timings.length !== expected.cycles) {
|
| 221 |
+
throw new Error(
|
| 222 |
+
`worker size ${expected.size} returned ${Array.isArray(timings) ? timings.length : "invalid"} ${phase} samples; expected ${expected.cycles}`,
|
| 223 |
+
);
|
| 224 |
+
}
|
| 225 |
+
timings.forEach((timing, index) =>
|
| 226 |
+
assertFiniteNonNegative(timing, `timingsMs.${phase}[${index}]`),
|
| 227 |
+
);
|
| 228 |
+
}
|
| 229 |
+
if (!isRecord(value.memory)) {
|
| 230 |
+
throw new Error("worker result memory must be an object");
|
| 231 |
+
}
|
| 232 |
+
const postGcBaseline = value.memory.postGcBaseline;
|
| 233 |
+
validateMemorySample(postGcBaseline, -1, "memory.postGcBaseline");
|
| 234 |
+
if (
|
| 235 |
+
!Array.isArray(value.memory.postGcSamples) ||
|
| 236 |
+
value.memory.postGcSamples.length !== expected.cycles
|
| 237 |
+
) {
|
| 238 |
+
throw new Error(
|
| 239 |
+
`worker size ${expected.size} returned invalid post-GC sample count; expected ${expected.cycles}`,
|
| 240 |
+
);
|
| 241 |
+
}
|
| 242 |
+
value.memory.postGcSamples.forEach((sample, index) =>
|
| 243 |
+
validateMemorySample(sample, index, `memory.postGcSamples[${index}]`),
|
| 244 |
+
);
|
| 245 |
+
const postGcSamples = value.memory.postGcSamples as MemorySample[];
|
| 246 |
+
if (!isRecord(value.memory.retainedSlopesBytesPerCycle)) {
|
| 247 |
+
throw new Error("worker result retained memory slopes must be an object");
|
| 248 |
+
}
|
| 249 |
+
if (!isRecord(value.memory.retainedDeltasBytes)) {
|
| 250 |
+
throw new Error("worker result retained memory deltas must be an object");
|
| 251 |
+
}
|
| 252 |
+
const finalPostGcSample = postGcSamples.at(-1);
|
| 253 |
+
if (!finalPostGcSample) {
|
| 254 |
+
throw new Error("worker result must include a final post-GC sample");
|
| 255 |
+
}
|
| 256 |
+
for (const field of RETAINED_MEMORY_FIELDS) {
|
| 257 |
+
assertFinite(
|
| 258 |
+
value.memory.retainedSlopesBytesPerCycle[field],
|
| 259 |
+
`memory.retainedSlopesBytesPerCycle.${field}`,
|
| 260 |
+
);
|
| 261 |
+
assertFinite(value.memory.retainedDeltasBytes[field], `memory.retainedDeltasBytes.${field}`);
|
| 262 |
+
const expectedDelta = finalPostGcSample[field] - postGcBaseline[field];
|
| 263 |
+
if (value.memory.retainedDeltasBytes[field] !== expectedDelta) {
|
| 264 |
+
throw new Error(
|
| 265 |
+
`worker result memory.retainedDeltasBytes.${field} must be end minus baseline`,
|
| 266 |
+
);
|
| 267 |
+
}
|
| 268 |
+
}
|
| 269 |
+
if ("processPeakRssBytes" in value.memory.retainedSlopesBytesPerCycle) {
|
| 270 |
+
throw new Error("worker result retained memory slopes must exclude process peak RSS");
|
| 271 |
+
}
|
| 272 |
+
if ("processPeakRssBytes" in value.memory.retainedDeltasBytes) {
|
| 273 |
+
throw new Error("worker result retained memory deltas must exclude process peak RSS");
|
| 274 |
+
}
|
| 275 |
+
assertFiniteNonNegative(value.memory.processPeakRssBytes, "memory.processPeakRssBytes");
|
| 276 |
+
if (!isRecord(value.invariant)) {
|
| 277 |
+
throw new Error("worker result invariant must be an object");
|
| 278 |
+
}
|
| 279 |
+
assertFiniteNonNegative(value.invariant.cyclesValidated, "invariant.cyclesValidated");
|
| 280 |
+
const emptyCounts: RegistryLifecycleCounts = {
|
| 281 |
+
taskCount: 0,
|
| 282 |
+
deliveryStateCount: 0,
|
| 283 |
+
runningTasks: 0,
|
| 284 |
+
succeededTasks: 0,
|
| 285 |
+
pendingDeliveryTasks: 0,
|
| 286 |
+
succeededTerminalOutcomes: 0,
|
| 287 |
+
};
|
| 288 |
+
validateRegistrySnapshot(value.invariant.registration, "invariant.registration", {
|
| 289 |
+
...emptyCounts,
|
| 290 |
+
taskCount: expected.size,
|
| 291 |
+
deliveryStateCount: expected.size,
|
| 292 |
+
runningTasks: expected.size,
|
| 293 |
+
pendingDeliveryTasks: expected.size,
|
| 294 |
+
});
|
| 295 |
+
validateRegistrySnapshot(value.invariant.terminal, "invariant.terminal", {
|
| 296 |
+
...emptyCounts,
|
| 297 |
+
taskCount: expected.size,
|
| 298 |
+
deliveryStateCount: expected.size,
|
| 299 |
+
succeededTasks: expected.size,
|
| 300 |
+
pendingDeliveryTasks: expected.size,
|
| 301 |
+
succeededTerminalOutcomes: expected.size,
|
| 302 |
+
});
|
| 303 |
+
validateRegistrySnapshot(value.invariant.teardown, "invariant.teardown", emptyCounts);
|
| 304 |
+
if (
|
| 305 |
+
value.invariant.ok !== true ||
|
| 306 |
+
value.invariant.serializedSharedConnection !== true ||
|
| 307 |
+
value.invariant.cyclesValidated !== expected.cycles + expected.warmup
|
| 308 |
+
) {
|
| 309 |
+
throw new Error(`worker size ${expected.size} reported a failed invariant`);
|
| 310 |
+
}
|
| 311 |
+
return value as WorkerResult;
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
function parseWorkerProcessResult(
|
| 315 |
+
result: BenchmarkWorkerProcessResult,
|
| 316 |
+
expected: { size: number; cycles: number; warmup: number },
|
| 317 |
+
): WorkerResult {
|
| 318 |
+
return parseBenchmarkWorkerResult({
|
| 319 |
+
result,
|
| 320 |
+
label: `size ${expected.size}`,
|
| 321 |
+
sentinel: WORKER_RESULT_SENTINEL,
|
| 322 |
+
timeoutMs: WORKER_TIMEOUT_MS,
|
| 323 |
+
validate: (value) => validateWorkerResult(value, expected),
|
| 324 |
+
});
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
function runWorker(
|
| 328 |
+
options: Options,
|
| 329 |
+
size: number,
|
| 330 |
+
runtime: WorkerLaunchRuntime = {},
|
| 331 |
+
): WorkerResult {
|
| 332 |
+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-task-registry-bench-"));
|
| 333 |
+
try {
|
| 334 |
+
return runBenchmarkWorker({
|
| 335 |
+
args: [
|
| 336 |
+
"--expose-gc",
|
| 337 |
+
"--import",
|
| 338 |
+
"tsx",
|
| 339 |
+
"scripts/bench-task-registry-sqlite-worker.ts",
|
| 340 |
+
"--size",
|
| 341 |
+
String(size),
|
| 342 |
+
"--cycles",
|
| 343 |
+
String(options.cycles),
|
| 344 |
+
"--warmup",
|
| 345 |
+
String(options.warmup),
|
| 346 |
+
"--state-dir",
|
| 347 |
+
stateDir,
|
| 348 |
+
],
|
| 349 |
+
label: `size ${size}`,
|
| 350 |
+
sentinel: WORKER_RESULT_SENTINEL,
|
| 351 |
+
spawnWorker: runtime.spawnWorker,
|
| 352 |
+
timeoutMs: WORKER_TIMEOUT_MS,
|
| 353 |
+
validate: (value) =>
|
| 354 |
+
validateWorkerResult(value, {
|
| 355 |
+
size,
|
| 356 |
+
cycles: options.cycles,
|
| 357 |
+
warmup: options.warmup,
|
| 358 |
+
}),
|
| 359 |
+
});
|
| 360 |
+
} finally {
|
| 361 |
+
fs.rmSync(stateDir, { recursive: true, force: true });
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function aggregateWorkerResults(options: Options, workers: WorkerResult[]) {
|
| 366 |
+
const bySize = new Map(workers.map((worker) => [worker.size, worker]));
|
| 367 |
+
if (bySize.size !== workers.length) {
|
| 368 |
+
throw new Error("worker results contain duplicate sizes");
|
| 369 |
+
}
|
| 370 |
+
const missing = options.sizes.filter((size) => !bySize.has(size));
|
| 371 |
+
const unexpected = [...bySize.keys()].filter((size) => !options.sizes.includes(size));
|
| 372 |
+
if (missing.length > 0 || unexpected.length > 0) {
|
| 373 |
+
throw new Error(
|
| 374 |
+
`worker result mismatch: missing=${missing.join(",") || "none"} unexpected=${unexpected.join(",") || "none"}`,
|
| 375 |
+
);
|
| 376 |
+
}
|
| 377 |
+
const sizes = options.sizes.map((size) => {
|
| 378 |
+
const worker = bySize.get(size);
|
| 379 |
+
if (!worker) {
|
| 380 |
+
throw new Error(`missing worker result for size ${size}`);
|
| 381 |
+
}
|
| 382 |
+
return {
|
| 383 |
+
size,
|
| 384 |
+
timingsMs: {
|
| 385 |
+
registration: summarizeBenchmarkTimings(worker.timingsMs.registration),
|
| 386 |
+
terminal: summarizeBenchmarkTimings(worker.timingsMs.terminal),
|
| 387 |
+
teardown: summarizeBenchmarkTimings(worker.timingsMs.teardown),
|
| 388 |
+
},
|
| 389 |
+
memory: worker.memory,
|
| 390 |
+
invariant: worker.invariant,
|
| 391 |
+
};
|
| 392 |
+
});
|
| 393 |
+
const failures = sizes
|
| 394 |
+
.filter((entry) => !entry.invariant.ok)
|
| 395 |
+
.map((entry) => `size:${entry.size}`);
|
| 396 |
+
return {
|
| 397 |
+
schemaVersion: 1,
|
| 398 |
+
benchmark: "durable-task-registry-churn",
|
| 399 |
+
generatedAt: new Date().toISOString(),
|
| 400 |
+
runtime: { node: process.version, platform: process.platform, arch: process.arch },
|
| 401 |
+
model: {
|
| 402 |
+
unit: "subagent task-record registrations",
|
| 403 |
+
execution:
|
| 404 |
+
"serialized create, terminal, and delete calls through one process-local shared SQLite connection",
|
| 405 |
+
isolation: "fresh --expose-gc worker process per size",
|
| 406 |
+
workload:
|
| 407 |
+
"all task records start running with pending delivery, transition to succeeded with a succeeded terminal outcome, then delete",
|
| 408 |
+
},
|
| 409 |
+
interpretation: {
|
| 410 |
+
timings: "advisory only; this is not a concurrent SQLite writer benchmark",
|
| 411 |
+
memory:
|
| 412 |
+
"post-GC baseline, end-minus-baseline retained deltas, and retained slopes are diagnostic only; they neither claim nor rule out a memory leak",
|
| 413 |
+
},
|
| 414 |
+
options: {
|
| 415 |
+
sizes: options.sizes,
|
| 416 |
+
cycles: options.cycles,
|
| 417 |
+
warmup: options.warmup,
|
| 418 |
+
},
|
| 419 |
+
memory: {
|
| 420 |
+
workerProcessPeakRssBytes: Math.max(
|
| 421 |
+
...workers.map((worker) => worker.memory.processPeakRssBytes),
|
| 422 |
+
),
|
| 423 |
+
},
|
| 424 |
+
sizes,
|
| 425 |
+
invariants: {
|
| 426 |
+
ok: failures.length === 0,
|
| 427 |
+
failures,
|
| 428 |
+
exactRegistrationTerminalAndTeardownState: failures.length === 0,
|
| 429 |
+
zeroRowsAfterEveryTeardown: failures.length === 0,
|
| 430 |
+
},
|
| 431 |
+
};
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
function benchmark(options: Options) {
|
| 435 |
+
const workers = runBenchmarkJobs(options.sizes, {
|
| 436 |
+
prefix: "bench-task-registry-sqlite",
|
| 437 |
+
describe: (size) => `size=${size}`,
|
| 438 |
+
run: (size) => runWorker(options, size),
|
| 439 |
+
});
|
| 440 |
+
return aggregateWorkerResults(options, workers);
|
| 441 |
+
}
|
| 442 |
+
|
| 443 |
+
async function main(argv = process.argv.slice(2)): Promise<void> {
|
| 444 |
+
const options = parseOptions(argv);
|
| 445 |
+
if (options.help) {
|
| 446 |
+
process.stdout.write(usage());
|
| 447 |
+
return;
|
| 448 |
+
}
|
| 449 |
+
const report = benchmark(options);
|
| 450 |
+
emitBenchmarkReport(report, options, (result) => [
|
| 451 |
+
...result.sizes.map((entry) => {
|
| 452 |
+
const registration = entry.timingsMs.registration;
|
| 453 |
+
const heapSlope = entry.memory.retainedSlopesBytesPerCycle.heapUsedBytes;
|
| 454 |
+
return `size=${entry.size} registration-p50=${registration.p50.toFixed(3)}ms registration-max=${registration.max.toFixed(3)}ms post-gc-heap-slope=${heapSlope.toFixed(1)}B/cycle`;
|
| 455 |
+
}),
|
| 456 |
+
result.interpretation.timings,
|
| 457 |
+
result.interpretation.memory,
|
| 458 |
+
]);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
export const testing = {
|
| 462 |
+
aggregateWorkerResults,
|
| 463 |
+
parseOptions,
|
| 464 |
+
parseWorkerProcessResult,
|
| 465 |
+
runWorker,
|
| 466 |
+
};
|
| 467 |
+
|
| 468 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 469 |
+
await runBenchmarkEntrypoint("bench-task-registry-sqlite", main);
|
| 470 |
+
}
|
scripts/build-diffs-viewer-runtime.mts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Builds browser runtime bundles for the diffs viewer assets.
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
import { fileURLToPath } from "node:url";
|
| 6 |
+
import { build, type Plugin } from "esbuild";
|
| 7 |
+
import { writeGeneratedTextAsset } from "./lib/generated-text-asset.mts";
|
| 8 |
+
|
| 9 |
+
const modulePath = fileURLToPath(import.meta.url);
|
| 10 |
+
const repoRoot = path.resolve(path.dirname(modulePath), "..");
|
| 11 |
+
const pierreDiffsEmptySideEffectNamespace = "openclaw-diffs-empty-side-effect";
|
| 12 |
+
const pierreDiffsEmptySideEffectPath = "pierre-diffs-parse-decorations-side-effect";
|
| 13 |
+
|
| 14 |
+
const targets = {
|
| 15 |
+
curated: {
|
| 16 |
+
entry: "extensions/diffs/src/viewer-client.ts",
|
| 17 |
+
output: "extensions/diffs/assets/viewer-runtime.js",
|
| 18 |
+
shikiAlias: "scripts/diffs-shiki-curated.ts",
|
| 19 |
+
languagePackAvailable: false,
|
| 20 |
+
},
|
| 21 |
+
full: {
|
| 22 |
+
entry: "extensions/diffs/src/viewer-client.ts",
|
| 23 |
+
output: "extensions/diffs-language-pack/assets/viewer-runtime.js",
|
| 24 |
+
languagePackAvailable: true,
|
| 25 |
+
},
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
function toPosixPath(value: string) {
|
| 29 |
+
return value.replaceAll("\\", "/");
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Creates the esbuild plugin that neutralizes Pierre diffs' browser side-effect import.
|
| 34 |
+
*/
|
| 35 |
+
export function createPierreDiffsSideEffectImportPlugin(): {
|
| 36 |
+
name: string;
|
| 37 |
+
setup(buildContext: unknown): void;
|
| 38 |
+
};
|
| 39 |
+
export function createPierreDiffsSideEffectImportPlugin() {
|
| 40 |
+
return {
|
| 41 |
+
name: "openclaw-diffs-pierre-side-effect-imports",
|
| 42 |
+
setup(buildContext) {
|
| 43 |
+
buildContext.onResolve({ filter: /^diff$/ }, (args) => {
|
| 44 |
+
const importer = toPosixPath(args.importer);
|
| 45 |
+
if (!importer.endsWith("/@pierre/diffs/dist/utils/parseDiffDecorations.js")) {
|
| 46 |
+
return undefined;
|
| 47 |
+
}
|
| 48 |
+
return {
|
| 49 |
+
path: pierreDiffsEmptySideEffectPath,
|
| 50 |
+
namespace: pierreDiffsEmptySideEffectNamespace,
|
| 51 |
+
sideEffects: true,
|
| 52 |
+
};
|
| 53 |
+
});
|
| 54 |
+
buildContext.onLoad(
|
| 55 |
+
{
|
| 56 |
+
filter: /^pierre-diffs-parse-decorations-side-effect$/,
|
| 57 |
+
namespace: pierreDiffsEmptySideEffectNamespace,
|
| 58 |
+
},
|
| 59 |
+
() => ({
|
| 60 |
+
contents: "export {};\n",
|
| 61 |
+
loader: "js",
|
| 62 |
+
}),
|
| 63 |
+
);
|
| 64 |
+
},
|
| 65 |
+
} satisfies Plugin;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Builds one configured diffs viewer runtime target.
|
| 70 |
+
*/
|
| 71 |
+
async function buildDiffsViewerRuntime(targetName: string | undefined) {
|
| 72 |
+
const target = Object.entries(targets).find(([name]) => name === targetName)?.[1];
|
| 73 |
+
if (!target) {
|
| 74 |
+
throw new Error(
|
| 75 |
+
`Usage: node --import tsx scripts/build-diffs-viewer-runtime.mts ${Object.keys(targets).join("|")}`,
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
const outputPath = path.join(repoRoot, target.output);
|
| 80 |
+
const shikiAlias = "shikiAlias" in target ? target.shikiAlias : undefined;
|
| 81 |
+
const result = await build({
|
| 82 |
+
entryPoints: [path.join(repoRoot, target.entry)],
|
| 83 |
+
bundle: true,
|
| 84 |
+
platform: "browser",
|
| 85 |
+
target: "es2020",
|
| 86 |
+
format: "esm",
|
| 87 |
+
minify: true,
|
| 88 |
+
define: {
|
| 89 |
+
__OPENCLAW_DIFFS_LANGUAGE_PACK__: String(target.languagePackAvailable),
|
| 90 |
+
NaN: "Number.NaN",
|
| 91 |
+
},
|
| 92 |
+
legalComments: "none",
|
| 93 |
+
outfile: outputPath,
|
| 94 |
+
write: false,
|
| 95 |
+
plugins: [
|
| 96 |
+
createPierreDiffsSideEffectImportPlugin(),
|
| 97 |
+
...(shikiAlias
|
| 98 |
+
? [
|
| 99 |
+
{
|
| 100 |
+
name: "openclaw-diffs-curated-shiki",
|
| 101 |
+
setup(buildContext) {
|
| 102 |
+
buildContext.onResolve({ filter: /^shiki$/ }, () => ({
|
| 103 |
+
path: path.join(repoRoot, shikiAlias),
|
| 104 |
+
}));
|
| 105 |
+
},
|
| 106 |
+
} satisfies Plugin,
|
| 107 |
+
]
|
| 108 |
+
: []),
|
| 109 |
+
],
|
| 110 |
+
});
|
| 111 |
+
|
| 112 |
+
const outputFile = result.outputFiles?.[0];
|
| 113 |
+
if (!outputFile) {
|
| 114 |
+
throw new Error(`esbuild did not produce ${target.output}`);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
const runtime = outputFile.text.replace(/[ \t]+$/gm, "");
|
| 118 |
+
await writeGeneratedTextAsset(outputPath, runtime);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
if (process.argv[1] === modulePath) {
|
| 122 |
+
await buildDiffsViewerRuntime(process.argv[2]);
|
| 123 |
+
}
|
scripts/build-plugin-control-ui.mts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import fs from "node:fs/promises";
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import {
|
| 4 |
+
buildPluginControlUi,
|
| 5 |
+
writePluginBuildManifest,
|
| 6 |
+
} from "../src/cli/plugins-control-ui-build.js";
|
| 7 |
+
import { controlUiSource } from "../src/plugins/package-manifest.js";
|
| 8 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 9 |
+
|
| 10 |
+
const rootDir = process.cwd();
|
| 11 |
+
const packageManifest = JSON.parse(await fs.readFile(path.join(rootDir, "package.json"), "utf8"));
|
| 12 |
+
const manifest = JSON.parse(await fs.readFile(path.join(rootDir, "openclaw.plugin.json"), "utf8"));
|
| 13 |
+
if (process.argv.includes("--copy")) {
|
| 14 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 15 |
+
const pluginDir = path.relative(path.join(repoRoot, "extensions"), rootDir);
|
| 16 |
+
if (!pluginDir || pluginDir.includes(path.sep) || pluginDir.startsWith(".")) {
|
| 17 |
+
throw new Error("Bundled UI copy must run inside one bundled plugin package.");
|
| 18 |
+
}
|
| 19 |
+
const output = path.dirname(manifest.controlUi.entry);
|
| 20 |
+
if (!/^dist\/control-ui\/[a-f0-9]{64}$/u.test(output)) {
|
| 21 |
+
throw new Error("Build the plugin's immutable Control UI assets before copying.");
|
| 22 |
+
}
|
| 23 |
+
await fs.cp(
|
| 24 |
+
path.join(rootDir, output),
|
| 25 |
+
path.join(repoRoot, "dist/extensions", pluginDir, output),
|
| 26 |
+
{ recursive: true },
|
| 27 |
+
);
|
| 28 |
+
} else {
|
| 29 |
+
const source = controlUiSource(packageManifest);
|
| 30 |
+
if (!source) {
|
| 31 |
+
throw new Error("Missing package.json openclaw.controlUi browser entrypoint.");
|
| 32 |
+
}
|
| 33 |
+
manifest.controlUi = await buildPluginControlUi({ rootDir, source });
|
| 34 |
+
await writePluginBuildManifest(rootDir, manifest);
|
| 35 |
+
}
|
scripts/bundle-a2ui.mts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Runs bundled asset build hooks for the Canvas A2UI runtime.
|
| 4 |
+
import { pathToFileURL } from "node:url";
|
| 5 |
+
import { runBundledPluginAssetHooks } from "./bundled-plugin-assets.mts";
|
| 6 |
+
|
| 7 |
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
| 8 |
+
await runBundledPluginAssetHooks({ phase: "build", plugins: ["canvas"] });
|
| 9 |
+
}
|
scripts/capture-composer-mic-hover-proof.mts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { chromium } from "playwright";
|
| 4 |
+
// Captures composer mic-hover proof shots: idle vs hovered talk control, plus
|
| 5 |
+
// the mic button's x position in both states (the hover-shift regression).
|
| 6 |
+
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
|
| 7 |
+
import {
|
| 8 |
+
canRunPlaywrightChromium,
|
| 9 |
+
installMockGateway,
|
| 10 |
+
resolvePlaywrightChromiumExecutablePath,
|
| 11 |
+
startControlUiE2eServer,
|
| 12 |
+
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
| 13 |
+
import { readControlUiProofOption } from "./lib/control-ui-proof-args.mts";
|
| 14 |
+
|
| 15 |
+
const outputDir = createControlUiE2eArtifactDir(
|
| 16 |
+
"composer-mic-hover-proof",
|
| 17 |
+
readControlUiProofOption(process.argv, "output-dir") ??
|
| 18 |
+
".artifacts/control-ui-e2e/composer-mic-hover-proof",
|
| 19 |
+
);
|
| 20 |
+
const label = readControlUiProofOption(process.argv, "label") ?? "after";
|
| 21 |
+
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
| 22 |
+
if (!canRunPlaywrightChromium(executablePath)) {
|
| 23 |
+
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
const server = await startControlUiE2eServer(undefined, { source: true });
|
| 27 |
+
const browser = await chromium.launch({ executablePath });
|
| 28 |
+
const context = await browser.newContext({
|
| 29 |
+
colorScheme: "dark",
|
| 30 |
+
viewport: { width: 1280, height: 800 },
|
| 31 |
+
});
|
| 32 |
+
const page = await context.newPage();
|
| 33 |
+
page.setDefaultTimeout(30_000);
|
| 34 |
+
|
| 35 |
+
try {
|
| 36 |
+
await installMockGateway(page);
|
| 37 |
+
await page.goto(`${server.baseUrl}chat`);
|
| 38 |
+
const composer = page.locator(".agent-chat__input");
|
| 39 |
+
await composer.waitFor({ state: "visible" });
|
| 40 |
+
const voice = page.getByRole("button", { name: "Start voice input" });
|
| 41 |
+
await voice.waitFor({ state: "visible" });
|
| 42 |
+
|
| 43 |
+
await page.mouse.move(10, 10);
|
| 44 |
+
await page.waitForTimeout(400);
|
| 45 |
+
const composerBox = await composer.boundingBox();
|
| 46 |
+
if (!composerBox) {
|
| 47 |
+
throw new Error("composer has no layout box");
|
| 48 |
+
}
|
| 49 |
+
const clip = {
|
| 50 |
+
x: Math.max(0, composerBox.x - 12),
|
| 51 |
+
y: Math.max(0, composerBox.y - 12),
|
| 52 |
+
width: Math.min(1280, composerBox.width + 24),
|
| 53 |
+
height: composerBox.height + 24,
|
| 54 |
+
};
|
| 55 |
+
const idleVoiceBox = await voice.boundingBox();
|
| 56 |
+
await page.screenshot({ clip, path: path.join(outputDir, `${label}-idle.png`) });
|
| 57 |
+
|
| 58 |
+
await voice.hover();
|
| 59 |
+
await page.waitForTimeout(400);
|
| 60 |
+
const hoverVoiceBox = await voice.boundingBox();
|
| 61 |
+
await page.screenshot({ clip, path: path.join(outputDir, `${label}-hover.png`) });
|
| 62 |
+
|
| 63 |
+
const picker = page.getByRole("button", { name: "Microphone input" });
|
| 64 |
+
if (await picker.count()) {
|
| 65 |
+
await picker.hover();
|
| 66 |
+
await page.waitForTimeout(400);
|
| 67 |
+
await page.screenshot({ clip, path: path.join(outputDir, `${label}-hover-chevron.png`) });
|
| 68 |
+
await picker.click();
|
| 69 |
+
await page.waitForTimeout(500);
|
| 70 |
+
await page.screenshot({
|
| 71 |
+
path: path.join(outputDir, `${label}-picker-open.png`),
|
| 72 |
+
clip: { ...clip, y: Math.max(0, clip.y - 260), height: clip.height + 260 },
|
| 73 |
+
});
|
| 74 |
+
await page.keyboard.press("Escape");
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
if (!idleVoiceBox || !hoverVoiceBox) {
|
| 78 |
+
throw new Error("voice button has no layout box");
|
| 79 |
+
}
|
| 80 |
+
console.log(
|
| 81 |
+
JSON.stringify(
|
| 82 |
+
{
|
| 83 |
+
label,
|
| 84 |
+
idleVoiceX: idleVoiceBox.x,
|
| 85 |
+
hoverVoiceX: hoverVoiceBox.x,
|
| 86 |
+
hoverShiftPx: hoverVoiceBox.x - idleVoiceBox.x,
|
| 87 |
+
outputDir,
|
| 88 |
+
},
|
| 89 |
+
null,
|
| 90 |
+
2,
|
| 91 |
+
),
|
| 92 |
+
);
|
| 93 |
+
} finally {
|
| 94 |
+
await context.close();
|
| 95 |
+
await browser.close();
|
| 96 |
+
await server.close();
|
| 97 |
+
}
|
scripts/capture-injected-turn-notice-proof.mts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { chromium } from "playwright";
|
| 4 |
+
// Captures webchat proof that a CLI harness-injected user turn renders as a
|
| 5 |
+
// collapsed system notice while the operator's own message keeps its bubble.
|
| 6 |
+
// Usage: node --import tsx scripts/capture-injected-turn-notice-proof.mts [--mode after|before]
|
| 7 |
+
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
|
| 8 |
+
import {
|
| 9 |
+
canRunPlaywrightChromium,
|
| 10 |
+
installMockGateway,
|
| 11 |
+
resolvePlaywrightChromiumExecutablePath,
|
| 12 |
+
startControlUiE2eServer,
|
| 13 |
+
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
| 14 |
+
import { readControlUiProofOption } from "./lib/control-ui-proof-args.mts";
|
| 15 |
+
|
| 16 |
+
const mode = readControlUiProofOption(process.argv, "mode") ?? "after";
|
| 17 |
+
if (mode !== "after" && mode !== "before") {
|
| 18 |
+
throw new Error(`Expected --mode after|before, received ${mode}`);
|
| 19 |
+
}
|
| 20 |
+
const outputDir = createControlUiE2eArtifactDir(
|
| 21 |
+
"injected-turn-notice-proof",
|
| 22 |
+
readControlUiProofOption(process.argv, "output-dir") ??
|
| 23 |
+
".artifacts/control-ui-e2e/injected-turn-notice-proof",
|
| 24 |
+
);
|
| 25 |
+
|
| 26 |
+
const baseTime = Date.parse("2026-08-26T20:37:00.000Z");
|
| 27 |
+
const skillBody = [
|
| 28 |
+
"Base directory for this skill: /tmp/skills/autoreview",
|
| 29 |
+
"",
|
| 30 |
+
"# Auto Review",
|
| 31 |
+
"",
|
| 32 |
+
"Run the bundled structured review helper as a closeout check.",
|
| 33 |
+
].join("\n");
|
| 34 |
+
// Mirrors the chat.history payload proven by the gateway integration test:
|
| 35 |
+
// the operator turn is a plain user row; the harness-injected turn carries the
|
| 36 |
+
// provenance recorded by the claude-cli importer. Before the fix the importer
|
| 37 |
+
// dropped that provenance, so --mode before omits it to reproduce the shipped
|
| 38 |
+
// payload.
|
| 39 |
+
const historyMessages = [
|
| 40 |
+
{
|
| 41 |
+
role: "user",
|
| 42 |
+
content: "Run the autoreview skill on this branch.",
|
| 43 |
+
timestamp: baseTime,
|
| 44 |
+
__openclaw: { id: "user-1", seq: 1 },
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
role: "user",
|
| 48 |
+
content: [{ type: "text", text: skillBody }],
|
| 49 |
+
...(mode === "after"
|
| 50 |
+
? { provenance: { kind: "internal_system", sourceTool: "cli_harness_context" } }
|
| 51 |
+
: {}),
|
| 52 |
+
timestamp: baseTime + 1_000,
|
| 53 |
+
__openclaw: {
|
| 54 |
+
id: "skill-meta-1",
|
| 55 |
+
importedFrom: "claude-cli",
|
| 56 |
+
cliSessionId: "cli-session-1",
|
| 57 |
+
externalId: "skill-meta-1",
|
| 58 |
+
seq: 2,
|
| 59 |
+
},
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
role: "assistant",
|
| 63 |
+
content: "Autoreview finished: no findings.",
|
| 64 |
+
timestamp: baseTime + 2_000,
|
| 65 |
+
__openclaw: { id: "assistant-1", seq: 3 },
|
| 66 |
+
},
|
| 67 |
+
];
|
| 68 |
+
|
| 69 |
+
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
| 70 |
+
if (!canRunPlaywrightChromium(executablePath)) {
|
| 71 |
+
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const server = await startControlUiE2eServer(undefined, { source: true });
|
| 75 |
+
const browser = await chromium.launch({ executablePath });
|
| 76 |
+
try {
|
| 77 |
+
const context = await browser.newContext({
|
| 78 |
+
colorScheme: "dark",
|
| 79 |
+
locale: "en-US",
|
| 80 |
+
reducedMotion: "reduce",
|
| 81 |
+
serviceWorkers: "block",
|
| 82 |
+
viewport: { width: 1280, height: 900 },
|
| 83 |
+
});
|
| 84 |
+
const page = await context.newPage();
|
| 85 |
+
page.setDefaultTimeout(30_000);
|
| 86 |
+
await installMockGateway(page, { historyMessages });
|
| 87 |
+
await page.goto(`${server.baseUrl}chat`);
|
| 88 |
+
await page.getByText("Autoreview finished: no findings.").waitFor({ state: "visible" });
|
| 89 |
+
await page.getByText("Run the autoreview skill on this branch.").waitFor({ state: "visible" });
|
| 90 |
+
if (mode === "after") {
|
| 91 |
+
// The injected turn renders as a collapsed system notice; expand it so the
|
| 92 |
+
// capture shows the disclosure carrying the skill body.
|
| 93 |
+
const toggle = page.locator(".chat-notice__toggle");
|
| 94 |
+
await toggle.waitFor({ state: "visible" });
|
| 95 |
+
await toggle.click();
|
| 96 |
+
await page.getByText("Base directory for this skill:", { exact: false }).waitFor({
|
| 97 |
+
state: "visible",
|
| 98 |
+
});
|
| 99 |
+
} else {
|
| 100 |
+
await page.getByText("Base directory for this skill:", { exact: false }).waitFor({
|
| 101 |
+
state: "visible",
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
await page.evaluate(async () => {
|
| 105 |
+
await document.fonts.ready;
|
| 106 |
+
await new Promise<void>((resolve) => {
|
| 107 |
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
| 108 |
+
});
|
| 109 |
+
});
|
| 110 |
+
const target = path.join(outputDir, `injected-turn-${mode}.png`);
|
| 111 |
+
await page.locator(".chat-thread-inner").screenshot({ animations: "disabled", path: target });
|
| 112 |
+
console.log(`captured ${target}`);
|
| 113 |
+
await context.close();
|
| 114 |
+
} finally {
|
| 115 |
+
await browser.close();
|
| 116 |
+
await server.close();
|
| 117 |
+
}
|
scripts/capture-where-chip-auto-proof.mts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Captures placement, inventory, and context-meter proof in both themes.
|
| 3 |
+
import assert from "node:assert/strict";
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
import { chromium } from "playwright";
|
| 6 |
+
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
|
| 7 |
+
import {
|
| 8 |
+
canRunPlaywrightChromium,
|
| 9 |
+
installMockGateway,
|
| 10 |
+
resolvePlaywrightChromiumExecutablePath,
|
| 11 |
+
startControlUiE2eServer,
|
| 12 |
+
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
| 13 |
+
|
| 14 |
+
const captureLabel = process.argv[2] ?? "after";
|
| 15 |
+
const outputDir = createControlUiE2eArtifactDir(
|
| 16 |
+
"node-slot-pips-proof",
|
| 17 |
+
process.argv[3] ?? "/tmp/node-slot-pips-proof",
|
| 18 |
+
);
|
| 19 |
+
const remoteExec = process.argv[4] === "remote-exec";
|
| 20 |
+
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
| 21 |
+
if (!canRunPlaywrightChromium(executablePath)) {
|
| 22 |
+
throw new Error(`Playwright Chromium unavailable at ${executablePath}`);
|
| 23 |
+
}
|
| 24 |
+
const server = await startControlUiE2eServer(undefined, { source: true });
|
| 25 |
+
const browser = await chromium.launch({ executablePath });
|
| 26 |
+
const environments = [
|
| 27 |
+
{ id: "idle", label: "Idle runner", workerSlots: { total: 8, available: 8 } },
|
| 28 |
+
{ id: "partial", label: "Busy runner", workerSlots: { total: 8, available: 5 } },
|
| 29 |
+
{ id: "full", label: "Full runner", workerSlots: { total: 8, available: 0 } },
|
| 30 |
+
{ id: "offline", label: "Offline runner", workerSlots: { total: 8, available: 2 } },
|
| 31 |
+
{ id: "large", label: "Large runner", workerSlots: { total: 13, available: 6 } },
|
| 32 |
+
{ id: "exec", label: "Terminal runner" },
|
| 33 |
+
].map(({ id, label, workerSlots }) => ({
|
| 34 |
+
id: `node:${id}`,
|
| 35 |
+
label,
|
| 36 |
+
workerSlots,
|
| 37 |
+
type: "node",
|
| 38 |
+
status: id === "offline" ? "unavailable" : "available",
|
| 39 |
+
sessionHost: true,
|
| 40 |
+
platform: "linux",
|
| 41 |
+
capabilities: ["codex.exec-server"],
|
| 42 |
+
invocableCommands: ["codex.exec-server.stdio.v1"],
|
| 43 |
+
}));
|
| 44 |
+
try {
|
| 45 |
+
for (const mode of ["dark", "light"] as const) {
|
| 46 |
+
const context = await browser.newContext({
|
| 47 |
+
colorScheme: mode,
|
| 48 |
+
locale: "en-US",
|
| 49 |
+
viewport: { width: 1280, height: 900 },
|
| 50 |
+
});
|
| 51 |
+
const page = await context.newPage();
|
| 52 |
+
page.setDefaultTimeout(30_000);
|
| 53 |
+
const config = { ui: { prefs: { theme: "claw", themeMode: mode } } };
|
| 54 |
+
await installMockGateway(page, {
|
| 55 |
+
...(remoteExec
|
| 56 |
+
? {
|
| 57 |
+
agentModel: "openai/gpt-5.6-luna",
|
| 58 |
+
models: [
|
| 59 |
+
{
|
| 60 |
+
available: true,
|
| 61 |
+
id: "gpt-5.6-luna",
|
| 62 |
+
name: "GPT-5.6 Luna",
|
| 63 |
+
provider: "openai",
|
| 64 |
+
agentRuntime: {
|
| 65 |
+
id: "codex",
|
| 66 |
+
devicePlacementSupported: true,
|
| 67 |
+
source: "model",
|
| 68 |
+
devicePlacement: {
|
| 69 |
+
requiredNodeCommands: ["codex.exec-server.stdio.v1"],
|
| 70 |
+
consumesWorkerSlot: false,
|
| 71 |
+
},
|
| 72 |
+
},
|
| 73 |
+
},
|
| 74 |
+
],
|
| 75 |
+
}
|
| 76 |
+
: {}),
|
| 77 |
+
methodResponses: {
|
| 78 |
+
"config.get": { config, hash: "capacity-proof", valid: true, issues: [] },
|
| 79 |
+
"environments.list": {
|
| 80 |
+
environments,
|
| 81 |
+
profiles: [],
|
| 82 |
+
},
|
| 83 |
+
"node.list": {
|
| 84 |
+
nodes: environments.map((entry) => ({
|
| 85 |
+
nodeId: entry.id.slice(5),
|
| 86 |
+
displayName: entry.label,
|
| 87 |
+
platform: entry.platform,
|
| 88 |
+
paired: true,
|
| 89 |
+
connected: entry.status === "available",
|
| 90 |
+
approvalState: "approved",
|
| 91 |
+
caps: entry.capabilities,
|
| 92 |
+
commands: entry.invocableCommands,
|
| 93 |
+
workerSlots: entry.workerSlots,
|
| 94 |
+
})),
|
| 95 |
+
},
|
| 96 |
+
"sessions.list": {
|
| 97 |
+
count: 4,
|
| 98 |
+
ts: 0,
|
| 99 |
+
path: "",
|
| 100 |
+
defaults: {},
|
| 101 |
+
sessions: [
|
| 102 |
+
{ label: "Context OK", totalTokens: 40_000 },
|
| 103 |
+
{ label: "Context warning", totalTokens: 140_000 },
|
| 104 |
+
{ label: "Context danger", totalTokens: 180_000 },
|
| 105 |
+
{ label: "Context approximate", totalTokens: 180_000, totalTokensFresh: false },
|
| 106 |
+
].map(({ label, totalTokens, totalTokensFresh }, index) => ({
|
| 107 |
+
label,
|
| 108 |
+
totalTokens,
|
| 109 |
+
totalTokensFresh,
|
| 110 |
+
key: `agent:main:context-${index}`,
|
| 111 |
+
kind: "direct",
|
| 112 |
+
updatedAt: 0,
|
| 113 |
+
contextTokens: 200_000,
|
| 114 |
+
hasActiveRun: false,
|
| 115 |
+
})),
|
| 116 |
+
},
|
| 117 |
+
},
|
| 118 |
+
});
|
| 119 |
+
await page.goto(`${server.baseUrl}new`);
|
| 120 |
+
const chip = page.getByText("Local", { exact: true }).first();
|
| 121 |
+
await chip.waitFor();
|
| 122 |
+
await page.waitForFunction(
|
| 123 |
+
(themeMode) => document.documentElement.dataset.themeMode === themeMode,
|
| 124 |
+
mode,
|
| 125 |
+
);
|
| 126 |
+
await page.evaluate(() => document.fonts.ready);
|
| 127 |
+
await chip.click();
|
| 128 |
+
await page.waitForSelector('[data-value="auto-device"]');
|
| 129 |
+
await page.locator('[data-value="device:exec"]').waitFor();
|
| 130 |
+
if (remoteExec) {
|
| 131 |
+
assert.equal(await page.locator('[data-value="device:full"]').isEnabled(), true);
|
| 132 |
+
assert.equal(await page.locator('[data-value="device:exec"]').isEnabled(), true);
|
| 133 |
+
}
|
| 134 |
+
await page.screenshot({
|
| 135 |
+
path: path.join(outputDir, `${captureLabel}-${mode}-menu.png`),
|
| 136 |
+
animations: "disabled",
|
| 137 |
+
});
|
| 138 |
+
await page.locator('[data-value="auto-device"]').click();
|
| 139 |
+
await page.locator('#new-session-where-trigger[data-auto-device="true"]').waitFor();
|
| 140 |
+
if (remoteExec) {
|
| 141 |
+
await page.locator("#new-session-where-trigger").click();
|
| 142 |
+
await page.locator('[data-value="device:full"]').click();
|
| 143 |
+
await page.locator('#new-session-where-trigger[data-device-id="full"]').waitFor();
|
| 144 |
+
console.log("SHOT_OK", captureLabel, mode, outputDir);
|
| 145 |
+
await context.close();
|
| 146 |
+
continue;
|
| 147 |
+
}
|
| 148 |
+
await page.goto(`${server.baseUrl}settings/devices`);
|
| 149 |
+
await page.getByText("Terminal runner", { exact: true }).waitFor();
|
| 150 |
+
await page.screenshot({
|
| 151 |
+
path: path.join(outputDir, `${captureLabel}-${mode}-devices.png`),
|
| 152 |
+
animations: "disabled",
|
| 153 |
+
});
|
| 154 |
+
await page.goto(`${server.baseUrl}sessions`);
|
| 155 |
+
await page.locator(".session-context-meter").first().waitFor();
|
| 156 |
+
assert.equal(await page.locator(".session-context-meter").count(), 4);
|
| 157 |
+
await page.evaluate(() => document.fonts.ready);
|
| 158 |
+
await page.locator(".data-table-container").screenshot({
|
| 159 |
+
path: path.join(outputDir, `${captureLabel}-${mode}-sessions.png`),
|
| 160 |
+
animations: "disabled",
|
| 161 |
+
});
|
| 162 |
+
console.log("SHOT_OK", captureLabel, mode, outputDir);
|
| 163 |
+
await context.close();
|
| 164 |
+
}
|
| 165 |
+
} finally {
|
| 166 |
+
await browser.close();
|
| 167 |
+
await server.close();
|
| 168 |
+
}
|
scripts/capture-workboard-ui-proof.mts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import path from "node:path";
|
| 3 |
+
import { chromium } from "playwright";
|
| 4 |
+
import { createControlUiE2eArtifactDir } from "../ui/src/test-helpers/control-ui-e2e-artifacts.ts";
|
| 5 |
+
import {
|
| 6 |
+
canRunPlaywrightChromium,
|
| 7 |
+
resolvePlaywrightChromiumExecutablePath,
|
| 8 |
+
} from "../ui/src/test-helpers/control-ui-e2e.ts";
|
| 9 |
+
import { readControlUiProofOption } from "./lib/control-ui-proof-args.mts";
|
| 10 |
+
const DEFAULT_BASE_URL = "http://127.0.0.1:5187";
|
| 11 |
+
const DEFAULT_OUTPUT_DIR = path.resolve(".artifacts/control-ui-e2e/workboard-proof");
|
| 12 |
+
const WORKBOARD_SESSION_KEY = "agent:main:workboard-proof";
|
| 13 |
+
|
| 14 |
+
const baseUrl = new URL(readControlUiProofOption(process.argv, "base-url") ?? DEFAULT_BASE_URL);
|
| 15 |
+
const outputDir = createControlUiE2eArtifactDir(
|
| 16 |
+
"workboard-proof",
|
| 17 |
+
readControlUiProofOption(process.argv, "output-dir") ?? DEFAULT_OUTPUT_DIR,
|
| 18 |
+
);
|
| 19 |
+
const executablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
|
| 20 |
+
if (!canRunPlaywrightChromium(executablePath)) {
|
| 21 |
+
throw new Error(`Playwright Chromium is unavailable at ${executablePath}`);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const browser = await chromium.launch({ executablePath });
|
| 25 |
+
const context = await browser.newContext({
|
| 26 |
+
colorScheme: "light",
|
| 27 |
+
reducedMotion: "reduce",
|
| 28 |
+
viewport: { width: 1440, height: 900 },
|
| 29 |
+
});
|
| 30 |
+
const page = await context.newPage();
|
| 31 |
+
page.setDefaultTimeout(30_000);
|
| 32 |
+
|
| 33 |
+
const gatewayUrl = new URL(baseUrl);
|
| 34 |
+
gatewayUrl.protocol = gatewayUrl.protocol === "https:" ? "wss:" : "ws:";
|
| 35 |
+
const settingsKey = `openclaw.control.settings.v1:${gatewayUrl.origin}`;
|
| 36 |
+
await page.addInitScript(
|
| 37 |
+
({ key, sessionKey }) => {
|
| 38 |
+
localStorage.setItem(
|
| 39 |
+
key,
|
| 40 |
+
JSON.stringify({
|
| 41 |
+
boardSessionViews: { [sessionKey]: { activeTabId: "main" } },
|
| 42 |
+
theme: "light",
|
| 43 |
+
}),
|
| 44 |
+
);
|
| 45 |
+
},
|
| 46 |
+
{ key: settingsKey, sessionKey: WORKBOARD_SESSION_KEY },
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
try {
|
| 50 |
+
await page.goto(new URL("/workboard/peter-tasks", baseUrl).toString());
|
| 51 |
+
const automationChip = page.locator(".workboard-automation-chip");
|
| 52 |
+
await automationChip.waitFor();
|
| 53 |
+
if ((await automationChip.textContent())?.trim() !== "Automation") {
|
| 54 |
+
throw new Error("Workboard automation chip did not render its expected label");
|
| 55 |
+
}
|
| 56 |
+
await page.getByText("Prepare launch readiness checklist", { exact: true }).waitFor();
|
| 57 |
+
await page.screenshot({
|
| 58 |
+
animations: "disabled",
|
| 59 |
+
path: path.join(outputDir, "workboard-chip.png"),
|
| 60 |
+
});
|
| 61 |
+
|
| 62 |
+
await page.goto(new URL("/dashboard", baseUrl).toString());
|
| 63 |
+
const widget = page.locator('[data-test-id="workboard-board-widget"]');
|
| 64 |
+
await widget.waitFor();
|
| 65 |
+
await widget.getByRole("heading", { name: "Validate onboarding flow", exact: true }).waitFor();
|
| 66 |
+
await widget.getByRole("heading", { name: "Review accessibility audit", exact: true }).waitFor();
|
| 67 |
+
await page.locator(".board-session-surface--dock-hidden").waitFor();
|
| 68 |
+
const columnCount = await widget.locator(".workboard-column").count();
|
| 69 |
+
if (columnCount !== 6) {
|
| 70 |
+
throw new Error(`Expected 6 Workboard columns, received ${columnCount}`);
|
| 71 |
+
}
|
| 72 |
+
await page.screenshot({
|
| 73 |
+
animations: "disabled",
|
| 74 |
+
path: path.join(outputDir, "workboard-widget.png"),
|
| 75 |
+
});
|
| 76 |
+
} finally {
|
| 77 |
+
await context.close();
|
| 78 |
+
await browser.close();
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
console.log(`[workboard-ui-proof] ${outputDir}`);
|
scripts/changed-lanes.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { runTsxCliShim } from "./lib/tsx-cli-shim.mjs";
|
| 2 |
+
|
| 3 |
+
await runTsxCliShim(import.meta.url, {
|
| 4 |
+
implementation: "./changed-lanes.mts",
|
| 5 |
+
});
|
scripts/check-built-plugin-control-plane-modules.mts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Verifies built plugin control-plane artifacts through Node's native require(esm) path.
|
| 4 |
+
import { spawnSync } from "node:child_process";
|
| 5 |
+
import fs from "node:fs";
|
| 6 |
+
import path from "node:path";
|
| 7 |
+
import { pathToFileURL } from "node:url";
|
| 8 |
+
import type ts from "typescript";
|
| 9 |
+
import { resolveNodeRuntimeExecutable } from "../src/infra/node-runtime-executable.ts";
|
| 10 |
+
import { collectSourceCheckoutPluginBuildEntries } from "./lib/bundled-plugin-build-entries.mjs";
|
| 11 |
+
import { isRecord } from "./lib/record-shared.mjs";
|
| 12 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 13 |
+
import { getTypeScript } from "./lib/ts-guard-utils.mts";
|
| 14 |
+
|
| 15 |
+
type BuiltPluginControlPlaneModule = {
|
| 16 |
+
pluginId: string;
|
| 17 |
+
kind: string;
|
| 18 |
+
relativePath: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type BuiltPluginControlPlaneModuleFailure = BuiltPluginControlPlaneModule & {
|
| 22 |
+
error: string;
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
type BuiltDoctorContractClosureViolation = BuiltPluginControlPlaneModule & {
|
| 26 |
+
dependency: string;
|
| 27 |
+
importerPath: string;
|
| 28 |
+
};
|
| 29 |
+
|
| 30 |
+
type ProbeParams = {
|
| 31 |
+
rootDir?: string;
|
| 32 |
+
env?: NodeJS.ProcessEnv;
|
| 33 |
+
timeoutMs?: number;
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
const ROOT = resolveRepoRoot(import.meta.url);
|
| 37 |
+
const DIRECT_CONTRACT_ENTRIES = ["contract-api", "doctor-contract-api"];
|
| 38 |
+
const LEGACY_SETUP_PROPERTIES = new Map<string, string>([
|
| 39 |
+
["legacyStateMigrations", "channel-legacy-state-migrations"],
|
| 40 |
+
["legacySessionSurface", "channel-legacy-session-surface"],
|
| 41 |
+
["legacySessionSurfaces", "channel-legacy-session-surface"],
|
| 42 |
+
]);
|
| 43 |
+
const PROBE_RESULT_MARKER = "__OPENCLAW_PLUGIN_CONTROL_PLANE_PROBE__";
|
| 44 |
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
| 45 |
+
// Doctor enumeration cold-loads every declaring plugin's contract closure, so a
|
| 46 |
+
// doctor artifact must never reach the process-spawn graph. Requiring the artifact
|
| 47 |
+
// cannot prove this: plain Node resolves the whole graph fine, and the cost and the
|
| 48 |
+
// ESM-only transitive deps (execa -> npm-run-path -> unicorn-magic, which has no
|
| 49 |
+
// `require` condition) only surface on source-run hosts whose CJS-flavored resolver
|
| 50 |
+
// rejects them. `doctor-contract-closure-guard.test.ts` owns the same invariant over
|
| 51 |
+
// sources; bundling can merge runtime code into the artifact behind its back, so the
|
| 52 |
+
// built closure is checked here.
|
| 53 |
+
const FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES = ["execa"];
|
| 54 |
+
const REQUIRE_PROBE_SOURCE = String.raw`
|
| 55 |
+
const { createRequire } = require("node:module");
|
| 56 |
+
const path = require("node:path");
|
| 57 |
+
const targets = JSON.parse(Buffer.from(process.argv[1], "base64url").toString("utf8"));
|
| 58 |
+
const requireFromRoot = createRequire(path.join(process.cwd(), "package.json"));
|
| 59 |
+
const failures = [];
|
| 60 |
+
for (const target of targets) {
|
| 61 |
+
try {
|
| 62 |
+
requireFromRoot(path.resolve(process.cwd(), target.relativePath));
|
| 63 |
+
} catch (error) {
|
| 64 |
+
failures.push({
|
| 65 |
+
...target,
|
| 66 |
+
error: error instanceof Error ? (error.stack || error.message) : String(error),
|
| 67 |
+
});
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
process.stdout.write("\n${PROBE_RESULT_MARKER}" + JSON.stringify({ failures }));
|
| 71 |
+
`;
|
| 72 |
+
|
| 73 |
+
function propertyNameText(name: ts.PropertyName) {
|
| 74 |
+
const ts = getTypeScript();
|
| 75 |
+
return ts.isIdentifier(name) || ts.isStringLiteralLike(name) ? name.text : "";
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function listLegacySetupModuleSpecifiers(setupEntryPath: string) {
|
| 79 |
+
const ts = getTypeScript();
|
| 80 |
+
const source = fs.readFileSync(setupEntryPath, "utf8");
|
| 81 |
+
const sourceFile = ts.createSourceFile(setupEntryPath, source, ts.ScriptTarget.Latest, true);
|
| 82 |
+
const specifiers: Array<{ kind: string; specifier: string }> = [];
|
| 83 |
+
const visit = (node: ts.Node): void => {
|
| 84 |
+
if (ts.isPropertyAssignment(node) && ts.isObjectLiteralExpression(node.initializer)) {
|
| 85 |
+
const kind = LEGACY_SETUP_PROPERTIES.get(propertyNameText(node.name));
|
| 86 |
+
if (kind) {
|
| 87 |
+
const specifierProperty = node.initializer.properties.find(
|
| 88 |
+
(property) =>
|
| 89 |
+
ts.isPropertyAssignment(property) && propertyNameText(property.name) === "specifier",
|
| 90 |
+
);
|
| 91 |
+
if (
|
| 92 |
+
specifierProperty &&
|
| 93 |
+
ts.isPropertyAssignment(specifierProperty) &&
|
| 94 |
+
ts.isStringLiteralLike(specifierProperty.initializer)
|
| 95 |
+
) {
|
| 96 |
+
specifiers.push({ kind, specifier: specifierProperty.initializer.text });
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
ts.forEachChild(node, visit);
|
| 101 |
+
};
|
| 102 |
+
visit(sourceFile);
|
| 103 |
+
return specifiers;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/** Lists exact built doctor, contract, and channel legacy migration artifacts. */
|
| 107 |
+
export function listBuiltPluginControlPlaneModules(
|
| 108 |
+
params: Pick<ProbeParams, "rootDir" | "env"> = {},
|
| 109 |
+
) {
|
| 110 |
+
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
| 111 |
+
const extensionsDir = path.join(rootDir, "dist", "extensions");
|
| 112 |
+
if (!fs.existsSync(extensionsDir)) {
|
| 113 |
+
return [];
|
| 114 |
+
}
|
| 115 |
+
const sourceEntries = new Map(
|
| 116 |
+
(fs.existsSync(path.join(rootDir, "extensions"))
|
| 117 |
+
? collectSourceCheckoutPluginBuildEntries({ cwd: rootDir, env: params.env })
|
| 118 |
+
: []
|
| 119 |
+
).map((entry) => [entry.id, entry]),
|
| 120 |
+
);
|
| 121 |
+
const modules = new Map<string, BuiltPluginControlPlaneModule>();
|
| 122 |
+
for (const entry of fs
|
| 123 |
+
.readdirSync(extensionsDir, { withFileTypes: true })
|
| 124 |
+
.filter((candidate) => candidate.isDirectory())
|
| 125 |
+
.toSorted((left, right) => left.name.localeCompare(right.name))) {
|
| 126 |
+
const pluginId = entry.name;
|
| 127 |
+
const pluginDir = path.join(extensionsDir, pluginId);
|
| 128 |
+
// Packaged core artifacts use ESM; isolated source-checkout plugins use
|
| 129 |
+
// the same selected format as their builder and generated metadata.
|
| 130 |
+
const extension = sourceEntries.get(pluginId)?.runtimeExtension ?? ".js";
|
| 131 |
+
for (const entryName of DIRECT_CONTRACT_ENTRIES) {
|
| 132 |
+
const fileName = `${entryName}${extension}`;
|
| 133 |
+
const modulePath = path.join(pluginDir, fileName);
|
| 134 |
+
if (fs.existsSync(modulePath)) {
|
| 135 |
+
const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/");
|
| 136 |
+
modules.set(relativePath, {
|
| 137 |
+
pluginId,
|
| 138 |
+
kind: entryName === "doctor-contract-api" ? "doctor-contract" : "contract",
|
| 139 |
+
relativePath,
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
const setupEntryPath = path.join(pluginDir, `setup-entry${extension}`);
|
| 144 |
+
if (!fs.existsSync(setupEntryPath)) {
|
| 145 |
+
continue;
|
| 146 |
+
}
|
| 147 |
+
for (const { kind, specifier } of listLegacySetupModuleSpecifiers(setupEntryPath)) {
|
| 148 |
+
const modulePath = path.resolve(pluginDir, specifier);
|
| 149 |
+
const pluginRelativePath = path.relative(pluginDir, modulePath);
|
| 150 |
+
if (pluginRelativePath.startsWith(`..${path.sep}`) || path.isAbsolute(pluginRelativePath)) {
|
| 151 |
+
throw new Error(`${pluginId} setup entry module escapes the plugin root: ${specifier}`);
|
| 152 |
+
}
|
| 153 |
+
const relativePath = path.relative(rootDir, modulePath).split(path.sep).join("/");
|
| 154 |
+
modules.set(relativePath, { pluginId, kind, relativePath });
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
return [...modules.values()].toSorted((left, right) =>
|
| 158 |
+
left.relativePath.localeCompare(right.relativePath),
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/** Loads every selected artifact in one timeout-bounded native-require child. */
|
| 163 |
+
export function probeBuiltPluginControlPlaneModules(
|
| 164 |
+
modules: BuiltPluginControlPlaneModule[],
|
| 165 |
+
params: ProbeParams = {},
|
| 166 |
+
) {
|
| 167 |
+
if (modules.length === 0) {
|
| 168 |
+
return [];
|
| 169 |
+
}
|
| 170 |
+
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
| 171 |
+
const encodedTargets = Buffer.from(JSON.stringify(modules), "utf8").toString("base64url");
|
| 172 |
+
const result = spawnSync(
|
| 173 |
+
resolveNodeRuntimeExecutable() ?? process.execPath,
|
| 174 |
+
["-e", REQUIRE_PROBE_SOURCE, encodedTargets],
|
| 175 |
+
{
|
| 176 |
+
cwd: rootDir,
|
| 177 |
+
encoding: "utf8",
|
| 178 |
+
maxBuffer: 8 * 1024 * 1024,
|
| 179 |
+
timeout: params.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
| 180 |
+
},
|
| 181 |
+
);
|
| 182 |
+
if (result.error) {
|
| 183 |
+
throw new Error(
|
| 184 |
+
`built plugin control-plane native-require probe failed: ${result.error.message}`,
|
| 185 |
+
);
|
| 186 |
+
}
|
| 187 |
+
const markerIndex = result.stdout.lastIndexOf(PROBE_RESULT_MARKER);
|
| 188 |
+
if (markerIndex < 0) {
|
| 189 |
+
throw new Error(
|
| 190 |
+
`built plugin control-plane native-require probe exited ${String(result.status)} without a result`,
|
| 191 |
+
);
|
| 192 |
+
}
|
| 193 |
+
const payload: unknown = JSON.parse(
|
| 194 |
+
result.stdout.slice(markerIndex + PROBE_RESULT_MARKER.length),
|
| 195 |
+
);
|
| 196 |
+
if (!isRecord(payload) || !Array.isArray(payload.failures)) {
|
| 197 |
+
return [];
|
| 198 |
+
}
|
| 199 |
+
return payload.failures.filter(
|
| 200 |
+
(failure): failure is BuiltPluginControlPlaneModuleFailure =>
|
| 201 |
+
isRecord(failure) &&
|
| 202 |
+
typeof failure.pluginId === "string" &&
|
| 203 |
+
typeof failure.kind === "string" &&
|
| 204 |
+
typeof failure.relativePath === "string" &&
|
| 205 |
+
typeof failure.error === "string",
|
| 206 |
+
);
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
// Follow ESM declarations and eager CJS require calls emitted by isolated builds.
|
| 210 |
+
// Dynamic imports and requires inside functions are lazy, not enumeration costs.
|
| 211 |
+
function parseStaticModuleSpecifiers(source: string, filePath: string): string[] {
|
| 212 |
+
const ts = getTypeScript();
|
| 213 |
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true);
|
| 214 |
+
const specifiers: string[] = [];
|
| 215 |
+
const visit = (node: ts.Node): void => {
|
| 216 |
+
if (ts.isFunctionLike(node)) {
|
| 217 |
+
return;
|
| 218 |
+
}
|
| 219 |
+
const moduleSpecifier =
|
| 220 |
+
ts.isImportDeclaration(node) || ts.isExportDeclaration(node)
|
| 221 |
+
? node.moduleSpecifier
|
| 222 |
+
: ts.isCallExpression(node) &&
|
| 223 |
+
ts.isIdentifier(node.expression) &&
|
| 224 |
+
/^(?:require|_+require\d*)$/u.test(node.expression.text)
|
| 225 |
+
? node.arguments[0]
|
| 226 |
+
: undefined;
|
| 227 |
+
if (moduleSpecifier && ts.isStringLiteralLike(moduleSpecifier)) {
|
| 228 |
+
specifiers.push(moduleSpecifier.text);
|
| 229 |
+
}
|
| 230 |
+
ts.forEachChild(node, visit);
|
| 231 |
+
};
|
| 232 |
+
visit(sourceFile);
|
| 233 |
+
return specifiers;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
function resolveBuiltChunkPath(importerPath: string, specifier: string): string | undefined {
|
| 237 |
+
const target = path.resolve(path.dirname(importerPath), specifier);
|
| 238 |
+
// Generated chunk edges carry their exact output suffix, including CJS.
|
| 239 |
+
return fs.existsSync(target) && fs.statSync(target).isFile() ? target : undefined;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
/** Collects the bare dependencies a built artifact reaches through static imports. */
|
| 243 |
+
function collectBuiltModuleStaticDependencies(entryPath: string): Map<string, string> {
|
| 244 |
+
const dependencies = new Map<string, string>();
|
| 245 |
+
const visited = new Set<string>();
|
| 246 |
+
const pending: string[] = [entryPath];
|
| 247 |
+
while (pending.length > 0) {
|
| 248 |
+
const filePath = pending.pop();
|
| 249 |
+
if (!filePath || visited.has(filePath)) {
|
| 250 |
+
continue;
|
| 251 |
+
}
|
| 252 |
+
visited.add(filePath);
|
| 253 |
+
let source: string;
|
| 254 |
+
try {
|
| 255 |
+
source = fs.readFileSync(filePath, "utf8");
|
| 256 |
+
} catch {
|
| 257 |
+
continue;
|
| 258 |
+
}
|
| 259 |
+
for (const reference of parseStaticModuleSpecifiers(source, filePath)) {
|
| 260 |
+
if (reference.startsWith(".") || reference.startsWith("/")) {
|
| 261 |
+
const resolved = resolveBuiltChunkPath(filePath, reference);
|
| 262 |
+
if (resolved) {
|
| 263 |
+
pending.push(resolved);
|
| 264 |
+
}
|
| 265 |
+
continue;
|
| 266 |
+
}
|
| 267 |
+
if (!reference.startsWith("node:") && !dependencies.has(reference)) {
|
| 268 |
+
dependencies.set(reference, filePath);
|
| 269 |
+
}
|
| 270 |
+
}
|
| 271 |
+
}
|
| 272 |
+
return dependencies;
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
/** Fails when a built doctor artifact statically reaches a forbidden runtime dependency. */
|
| 276 |
+
export function collectBuiltDoctorContractClosureViolations(
|
| 277 |
+
modules: BuiltPluginControlPlaneModule[],
|
| 278 |
+
params: { rootDir?: string } = {},
|
| 279 |
+
): BuiltDoctorContractClosureViolation[] {
|
| 280 |
+
const rootDir = path.resolve(params.rootDir ?? ROOT);
|
| 281 |
+
const violations: BuiltDoctorContractClosureViolation[] = [];
|
| 282 |
+
for (const module of modules.filter((candidate) => candidate.kind === "doctor-contract")) {
|
| 283 |
+
const dependencies = collectBuiltModuleStaticDependencies(
|
| 284 |
+
path.join(rootDir, module.relativePath),
|
| 285 |
+
);
|
| 286 |
+
for (const dependency of FORBIDDEN_DOCTOR_CONTRACT_DEPENDENCIES) {
|
| 287 |
+
const importer = dependencies.get(dependency);
|
| 288 |
+
if (importer) {
|
| 289 |
+
violations.push({
|
| 290 |
+
...module,
|
| 291 |
+
dependency,
|
| 292 |
+
importerPath: path.relative(rootDir, importer).split(path.sep).join("/"),
|
| 293 |
+
});
|
| 294 |
+
}
|
| 295 |
+
}
|
| 296 |
+
}
|
| 297 |
+
return violations;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
/** Fails the build when a generated plugin control-plane module cannot be required natively. */
|
| 301 |
+
export function verifyBuiltPluginControlPlaneModules(params: ProbeParams = {}) {
|
| 302 |
+
const modules = listBuiltPluginControlPlaneModules(params);
|
| 303 |
+
const failures = probeBuiltPluginControlPlaneModules(modules, params);
|
| 304 |
+
if (failures.length > 0) {
|
| 305 |
+
const details = failures.map(
|
| 306 |
+
(failure) =>
|
| 307 |
+
`- ${failure.pluginId} (${failure.kind}) ${failure.relativePath}: ${failure.error}`,
|
| 308 |
+
);
|
| 309 |
+
throw new Error(`built plugin control-plane module load failures:\n${details.join("\n")}`);
|
| 310 |
+
}
|
| 311 |
+
const closureViolations = collectBuiltDoctorContractClosureViolations(modules, params);
|
| 312 |
+
if (closureViolations.length > 0) {
|
| 313 |
+
const details = closureViolations.map(
|
| 314 |
+
(violation) =>
|
| 315 |
+
`- ${violation.pluginId} ${violation.relativePath} statically reaches ${violation.dependency} through ${violation.importerPath}`,
|
| 316 |
+
);
|
| 317 |
+
throw new Error(
|
| 318 |
+
`built doctor contract closures reach forbidden runtime dependencies:\n${details.join("\n")}`,
|
| 319 |
+
);
|
| 320 |
+
}
|
| 321 |
+
console.error(
|
| 322 |
+
`[plugin-control-plane-loads] verified ${modules.length} built modules with native require and checked doctor closures`,
|
| 323 |
+
);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
| 327 |
+
verifyBuiltPluginControlPlaneModules();
|
| 328 |
+
}
|
scripts/check-changed.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { runTsxCliShim } from "./lib/tsx-cli-shim.mjs";
|
| 2 |
+
|
| 3 |
+
await runTsxCliShim(import.meta.url, {
|
| 4 |
+
implementation: "./check-changed.mts",
|
| 5 |
+
});
|
scripts/check-changelog-attributions.mjs
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { runNodeCliShim } from "./lib/tsx-cli-shim.mjs";
|
| 3 |
+
|
| 4 |
+
await runNodeCliShim(import.meta.url, { implementation: "./check-changelog-attributions.mts" });
|
scripts/check-channel-agnostic-boundaries.mts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Checks channel-agnostic core surfaces for channel-specific coupling.
|
| 4 |
+
import { promises as fs } from "node:fs";
|
| 5 |
+
import path from "node:path";
|
| 6 |
+
import ts from "typescript";
|
| 7 |
+
import { visitModuleSpecifiers } from "./lib/guard-inventory-utils.mjs";
|
| 8 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 9 |
+
import {
|
| 10 |
+
collectTypeScriptFiles,
|
| 11 |
+
getPropertyNameText,
|
| 12 |
+
runAsScript,
|
| 13 |
+
toLine,
|
| 14 |
+
} from "./lib/ts-guard-utils.mts";
|
| 15 |
+
|
| 16 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 17 |
+
|
| 18 |
+
const acpCoreProtectedSources = [
|
| 19 |
+
path.join(repoRoot, "src", "acp"),
|
| 20 |
+
path.join(repoRoot, "src", "agents", "subagents", "spawn", "acp-spawn.ts"),
|
| 21 |
+
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
| 22 |
+
path.join(repoRoot, "src", "infra", "outbound", "conversation-id.ts"),
|
| 23 |
+
];
|
| 24 |
+
|
| 25 |
+
const channelCoreProtectedSources = [
|
| 26 |
+
path.join(repoRoot, "src", "channels", "thread-bindings-policy.ts"),
|
| 27 |
+
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
|
| 28 |
+
path.join(repoRoot, "src", "sessions", "send-policy.ts"),
|
| 29 |
+
path.join(repoRoot, "src", "sessions", "session-chat-type-shared.ts"),
|
| 30 |
+
path.join(repoRoot, "src", "utils", "delivery-context.shared.ts"),
|
| 31 |
+
];
|
| 32 |
+
const acpUserFacingTextSources = [
|
| 33 |
+
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
| 34 |
+
];
|
| 35 |
+
const systemMarkLiteralGuardSources = [
|
| 36 |
+
path.join(repoRoot, "src", "auto-reply", "reply", "commands-acp"),
|
| 37 |
+
path.join(repoRoot, "src", "auto-reply", "reply", "dispatch-acp.ts"),
|
| 38 |
+
path.join(repoRoot, "src", "auto-reply", "reply", "directive-handling.shared.ts"),
|
| 39 |
+
path.join(repoRoot, "src", "channels", "thread-bindings-messages.ts"),
|
| 40 |
+
];
|
| 41 |
+
|
| 42 |
+
const channelIds = [
|
| 43 |
+
"discord",
|
| 44 |
+
"googlechat",
|
| 45 |
+
"imessage",
|
| 46 |
+
"irc",
|
| 47 |
+
"line",
|
| 48 |
+
"mattermost",
|
| 49 |
+
"matrix",
|
| 50 |
+
"msteams",
|
| 51 |
+
"nextcloud-talk",
|
| 52 |
+
"nostr",
|
| 53 |
+
"qqbot",
|
| 54 |
+
"signal",
|
| 55 |
+
"slack",
|
| 56 |
+
"synology-chat",
|
| 57 |
+
"telegram",
|
| 58 |
+
"tlon",
|
| 59 |
+
"twitch",
|
| 60 |
+
"web",
|
| 61 |
+
"whatsapp",
|
| 62 |
+
"zalo",
|
| 63 |
+
"zalouser",
|
| 64 |
+
];
|
| 65 |
+
|
| 66 |
+
const channelIdSet = new Set(channelIds);
|
| 67 |
+
const channelSegmentRe = new RegExp(`(^|[._/-])(?:${channelIds.join("|")})([._/-]|$)`);
|
| 68 |
+
const comparisonOperators: ReadonlySet<ts.SyntaxKind> = new Set([
|
| 69 |
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
| 70 |
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
| 71 |
+
ts.SyntaxKind.EqualsEqualsToken,
|
| 72 |
+
ts.SyntaxKind.ExclamationEqualsToken,
|
| 73 |
+
]);
|
| 74 |
+
|
| 75 |
+
const allowedViolations = new Set<string>();
|
| 76 |
+
|
| 77 |
+
type BoundaryViolation = { line: number; reason: string };
|
| 78 |
+
type BoundaryOptions = {
|
| 79 |
+
checkModuleSpecifiers?: boolean;
|
| 80 |
+
checkConfigPaths?: boolean;
|
| 81 |
+
checkChannelComparisons?: boolean;
|
| 82 |
+
checkChannelAssignments?: boolean;
|
| 83 |
+
moduleSpecifierMatcher?: (specifier: string) => boolean;
|
| 84 |
+
};
|
| 85 |
+
type ModuleSpecifierVisit = {
|
| 86 |
+
kind: string;
|
| 87 |
+
node: ts.Node;
|
| 88 |
+
specifier: string;
|
| 89 |
+
specifierNode: ts.Node;
|
| 90 |
+
};
|
| 91 |
+
function isChannelsPropertyAccess(node: ts.Node) {
|
| 92 |
+
if (ts.isPropertyAccessExpression(node)) {
|
| 93 |
+
return node.name.text === "channels";
|
| 94 |
+
}
|
| 95 |
+
if (ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression)) {
|
| 96 |
+
return node.argumentExpression.text === "channels";
|
| 97 |
+
}
|
| 98 |
+
return false;
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function readStringLiteral(node: ts.Node) {
|
| 102 |
+
if (ts.isStringLiteral(node)) {
|
| 103 |
+
return node.text;
|
| 104 |
+
}
|
| 105 |
+
if (ts.isNoSubstitutionTemplateLiteral(node)) {
|
| 106 |
+
return node.text;
|
| 107 |
+
}
|
| 108 |
+
return null;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
function isChannelLiteralNode(node: ts.Node) {
|
| 112 |
+
const text = readStringLiteral(node);
|
| 113 |
+
return text ? channelIdSet.has(text) : false;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
function matchesChannelModuleSpecifier(specifier: string) {
|
| 117 |
+
return channelSegmentRe.test(specifier.replaceAll("\\", "/"));
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const userFacingChannelNameRe =
|
| 121 |
+
/\b(?:discord|telegram|slack|signal|imessage|whatsapp|google\s*chat|irc|line|zalo|matrix|msteams)\b/i;
|
| 122 |
+
const systemMarkLiteral = "⚙️";
|
| 123 |
+
|
| 124 |
+
function isModuleSpecifierStringNode(node: ts.Node) {
|
| 125 |
+
const parent = node.parent;
|
| 126 |
+
if (ts.isImportDeclaration(parent) || ts.isExportDeclaration(parent)) {
|
| 127 |
+
return true;
|
| 128 |
+
}
|
| 129 |
+
return (
|
| 130 |
+
ts.isCallExpression(parent) &&
|
| 131 |
+
parent.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
| 132 |
+
parent.arguments[0] === node
|
| 133 |
+
);
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* Finds channel-specific references inside channel-agnostic protected sources.
|
| 138 |
+
*/
|
| 139 |
+
export function findChannelAgnosticBoundaryViolations(
|
| 140 |
+
content: string,
|
| 141 |
+
fileName = "source.ts",
|
| 142 |
+
options: BoundaryOptions = {},
|
| 143 |
+
) {
|
| 144 |
+
const checkModuleSpecifiers = options.checkModuleSpecifiers ?? true;
|
| 145 |
+
const checkConfigPaths = options.checkConfigPaths ?? true;
|
| 146 |
+
const checkChannelComparisons = options.checkChannelComparisons ?? true;
|
| 147 |
+
const checkChannelAssignments = options.checkChannelAssignments ?? true;
|
| 148 |
+
const moduleSpecifierMatcher = options.moduleSpecifierMatcher ?? matchesChannelModuleSpecifier;
|
| 149 |
+
|
| 150 |
+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
| 151 |
+
const violations: BoundaryViolation[] = [];
|
| 152 |
+
const moduleViolations = new Map<ts.Node, BoundaryViolation>();
|
| 153 |
+
if (checkModuleSpecifiers) {
|
| 154 |
+
visitModuleSpecifiers(
|
| 155 |
+
ts,
|
| 156 |
+
sourceFile,
|
| 157 |
+
({ kind, node, specifier, specifierNode }: ModuleSpecifierVisit) => {
|
| 158 |
+
if (moduleSpecifierMatcher(specifier)) {
|
| 159 |
+
const verb =
|
| 160 |
+
kind === "export"
|
| 161 |
+
? "re-exports"
|
| 162 |
+
: kind === "dynamic-import"
|
| 163 |
+
? "dynamically imports"
|
| 164 |
+
: "imports";
|
| 165 |
+
moduleViolations.set(node, {
|
| 166 |
+
line: toLine(sourceFile, specifierNode),
|
| 167 |
+
reason: verb + ' channel module "' + specifier + '"',
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
},
|
| 171 |
+
{ includeCommonJs: true, includeImportMetaUrl: true, includeImportTypes: true },
|
| 172 |
+
);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
const visit = (node: ts.Node): void => {
|
| 176 |
+
const moduleViolation = moduleViolations.get(node);
|
| 177 |
+
if (moduleViolation) {
|
| 178 |
+
violations.push(moduleViolation);
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (
|
| 182 |
+
checkConfigPaths &&
|
| 183 |
+
ts.isPropertyAccessExpression(node) &&
|
| 184 |
+
channelIdSet.has(node.name.text)
|
| 185 |
+
) {
|
| 186 |
+
if (isChannelsPropertyAccess(node.expression)) {
|
| 187 |
+
violations.push({
|
| 188 |
+
line: toLine(sourceFile, node.name),
|
| 189 |
+
reason: `references config path "channels.${node.name.text}"`,
|
| 190 |
+
});
|
| 191 |
+
}
|
| 192 |
+
}
|
| 193 |
+
|
| 194 |
+
if (
|
| 195 |
+
checkConfigPaths &&
|
| 196 |
+
ts.isElementAccessExpression(node) &&
|
| 197 |
+
ts.isStringLiteral(node.argumentExpression) &&
|
| 198 |
+
channelIdSet.has(node.argumentExpression.text)
|
| 199 |
+
) {
|
| 200 |
+
if (isChannelsPropertyAccess(node.expression)) {
|
| 201 |
+
violations.push({
|
| 202 |
+
line: toLine(sourceFile, node.argumentExpression),
|
| 203 |
+
reason: `references config path "channels[${JSON.stringify(node.argumentExpression.text)}]"`,
|
| 204 |
+
});
|
| 205 |
+
}
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
if (
|
| 209 |
+
checkChannelComparisons &&
|
| 210 |
+
ts.isBinaryExpression(node) &&
|
| 211 |
+
comparisonOperators.has(node.operatorToken.kind)
|
| 212 |
+
) {
|
| 213 |
+
if (isChannelLiteralNode(node.left) || isChannelLiteralNode(node.right)) {
|
| 214 |
+
const leftText = node.left.getText(sourceFile);
|
| 215 |
+
const rightText = node.right.getText(sourceFile);
|
| 216 |
+
violations.push({
|
| 217 |
+
line: toLine(sourceFile, node.operatorToken),
|
| 218 |
+
reason: `compares with channel id literal (${leftText} ${node.operatorToken.getText(sourceFile)} ${rightText})`,
|
| 219 |
+
});
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
if (checkChannelAssignments && ts.isPropertyAssignment(node)) {
|
| 224 |
+
const propName = getPropertyNameText(node.name);
|
| 225 |
+
if (propName === "channel" && isChannelLiteralNode(node.initializer)) {
|
| 226 |
+
violations.push({
|
| 227 |
+
line: toLine(sourceFile, node.initializer),
|
| 228 |
+
reason: `assigns channel id literal to "channel" (${node.initializer.getText(sourceFile)})`,
|
| 229 |
+
});
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
ts.forEachChild(node, visit);
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
visit(sourceFile);
|
| 237 |
+
return violations;
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
/**
|
| 241 |
+
* Finds reverse dependencies from channel core into plugin/runtime surfaces.
|
| 242 |
+
*/
|
| 243 |
+
export function findChannelCoreReverseDependencyViolations(
|
| 244 |
+
content: string,
|
| 245 |
+
fileName = "source.ts",
|
| 246 |
+
) {
|
| 247 |
+
return findChannelAgnosticBoundaryViolations(content, fileName, {
|
| 248 |
+
checkModuleSpecifiers: true,
|
| 249 |
+
checkConfigPaths: false,
|
| 250 |
+
checkChannelComparisons: false,
|
| 251 |
+
checkChannelAssignments: false,
|
| 252 |
+
moduleSpecifierMatcher: matchesChannelModuleSpecifier,
|
| 253 |
+
});
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
/**
|
| 257 |
+
* Finds user-facing channel names in ACP-owned text sources.
|
| 258 |
+
*/
|
| 259 |
+
export function findAcpUserFacingChannelNameViolations(content: string, fileName = "source.ts") {
|
| 260 |
+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
| 261 |
+
const violations: BoundaryViolation[] = [];
|
| 262 |
+
|
| 263 |
+
const visit = (node: ts.Node): void => {
|
| 264 |
+
const text = readStringLiteral(node);
|
| 265 |
+
if (text && userFacingChannelNameRe.test(text) && !isModuleSpecifierStringNode(node)) {
|
| 266 |
+
violations.push({
|
| 267 |
+
line: toLine(sourceFile, node),
|
| 268 |
+
reason: `user-facing text references channel name (${JSON.stringify(text)})`,
|
| 269 |
+
});
|
| 270 |
+
}
|
| 271 |
+
ts.forEachChild(node, visit);
|
| 272 |
+
};
|
| 273 |
+
|
| 274 |
+
visit(sourceFile);
|
| 275 |
+
return violations;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
/**
|
| 279 |
+
* Finds raw system mark literals where shared constants should be used.
|
| 280 |
+
*/
|
| 281 |
+
export function findSystemMarkLiteralViolations(content: string, fileName = "source.ts") {
|
| 282 |
+
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
| 283 |
+
const violations: BoundaryViolation[] = [];
|
| 284 |
+
|
| 285 |
+
const visit = (node: ts.Node): void => {
|
| 286 |
+
const text = readStringLiteral(node);
|
| 287 |
+
if (text && text.includes(systemMarkLiteral) && !isModuleSpecifierStringNode(node)) {
|
| 288 |
+
violations.push({
|
| 289 |
+
line: toLine(sourceFile, node),
|
| 290 |
+
reason: `hardcoded system mark literal (${JSON.stringify(text)})`,
|
| 291 |
+
});
|
| 292 |
+
}
|
| 293 |
+
ts.forEachChild(node, visit);
|
| 294 |
+
};
|
| 295 |
+
|
| 296 |
+
visit(sourceFile);
|
| 297 |
+
return violations;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
const boundaryRuleSets = [
|
| 301 |
+
{
|
| 302 |
+
id: "acp-core",
|
| 303 |
+
sources: acpCoreProtectedSources,
|
| 304 |
+
scan: findChannelAgnosticBoundaryViolations,
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
id: "channel-core-reverse-deps",
|
| 308 |
+
sources: channelCoreProtectedSources,
|
| 309 |
+
scan: findChannelCoreReverseDependencyViolations,
|
| 310 |
+
},
|
| 311 |
+
{
|
| 312 |
+
id: "acp-user-facing-text",
|
| 313 |
+
sources: acpUserFacingTextSources,
|
| 314 |
+
scan: findAcpUserFacingChannelNameViolations,
|
| 315 |
+
},
|
| 316 |
+
{
|
| 317 |
+
id: "system-mark-literal-usage",
|
| 318 |
+
sources: systemMarkLiteralGuardSources,
|
| 319 |
+
scan: findSystemMarkLiteralViolations,
|
| 320 |
+
},
|
| 321 |
+
];
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Runs all channel-agnostic boundary checks.
|
| 325 |
+
*/
|
| 326 |
+
export async function main() {
|
| 327 |
+
const violations: string[] = [];
|
| 328 |
+
for (const ruleSet of boundaryRuleSets) {
|
| 329 |
+
const files = (
|
| 330 |
+
await Promise.all(
|
| 331 |
+
ruleSet.sources.map(
|
| 332 |
+
async (sourcePath) =>
|
| 333 |
+
await collectTypeScriptFiles(sourcePath, {
|
| 334 |
+
ignoreMissing: true,
|
| 335 |
+
}),
|
| 336 |
+
),
|
| 337 |
+
)
|
| 338 |
+
).flat();
|
| 339 |
+
for (const filePath of files) {
|
| 340 |
+
const relativeFile = path.relative(repoRoot, filePath);
|
| 341 |
+
if (
|
| 342 |
+
allowedViolations.has(`${ruleSet.id}:${relativeFile}`) ||
|
| 343 |
+
allowedViolations.has(relativeFile)
|
| 344 |
+
) {
|
| 345 |
+
continue;
|
| 346 |
+
}
|
| 347 |
+
const content = await fs.readFile(filePath, "utf8");
|
| 348 |
+
for (const violation of ruleSet.scan(content, relativeFile)) {
|
| 349 |
+
violations.push(`${ruleSet.id} ${relativeFile}:${violation.line}: ${violation.reason}`);
|
| 350 |
+
}
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
if (violations.length === 0) {
|
| 355 |
+
return;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
console.error("Found channel-specific references in channel-agnostic sources:");
|
| 359 |
+
for (const violation of violations) {
|
| 360 |
+
console.error(`- ${violation}`);
|
| 361 |
+
}
|
| 362 |
+
console.error(
|
| 363 |
+
"Move channel-specific logic to channel adapters or add a justified allowlist entry.",
|
| 364 |
+
);
|
| 365 |
+
process.exit(1);
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
runAsScript(import.meta.url, main);
|
scripts/check-cli-startup-memory.mjs
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Measures CLI startup memory with an isolated home and an in-process bench entry.
|
| 3 |
+
import { spawnSync as defaultSpawnSync } from "node:child_process";
|
| 4 |
+
import {
|
| 5 |
+
closeSync,
|
| 6 |
+
constants as fsConstants,
|
| 7 |
+
fstatSync,
|
| 8 |
+
ftruncateSync,
|
| 9 |
+
lstatSync,
|
| 10 |
+
mkdirSync,
|
| 11 |
+
mkdtempSync,
|
| 12 |
+
openSync,
|
| 13 |
+
readlinkSync,
|
| 14 |
+
realpathSync,
|
| 15 |
+
rmSync,
|
| 16 |
+
rmdirSync,
|
| 17 |
+
statSync,
|
| 18 |
+
unlinkSync,
|
| 19 |
+
writeFileSync,
|
| 20 |
+
} from "node:fs";
|
| 21 |
+
import os from "node:os";
|
| 22 |
+
import path from "node:path";
|
| 23 |
+
import { pathToFileURL } from "node:url";
|
| 24 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 25 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 26 |
+
const tmpDir = process.env.TMPDIR || process.env.TEMP || process.env.TMP || os.tmpdir();
|
| 27 |
+
const MAX_RSS_MARKER = "__OPENCLAW_MAX_RSS_KB__=";
|
| 28 |
+
const DEFAULT_COMMAND_TIMEOUT_MS = 60_000;
|
| 29 |
+
const STARTUP_MEMORY_SAMPLE_COUNT = 3;
|
| 30 |
+
const STARTUP_MEMORY_RSS_TOLERANCE_MB = 1;
|
| 31 |
+
const COMMAND_TIMEOUT_MS = readPositiveIntEnv(
|
| 32 |
+
"OPENCLAW_STARTUP_MEMORY_TIMEOUT_MS",
|
| 33 |
+
DEFAULT_COMMAND_TIMEOUT_MS,
|
| 34 |
+
);
|
| 35 |
+
let tmpHome = null;
|
| 36 |
+
let benchEntryPath = null;
|
| 37 |
+
const PASS = "pass";
|
| 38 |
+
const FAIL = "fail";
|
| 39 |
+
const REPORT_PATH_FILE_ERROR = "--json and --summary must refer to files";
|
| 40 |
+
const REPORT_PATH_CHANGED_ERROR = "--json or --summary changed during startup benchmarks";
|
| 41 |
+
const REPORT_PATH_ALIAS_ERROR = "--json and --summary must refer to different files";
|
| 42 |
+
function readPositiveIntEnv(name, fallback, env = process.env) {
|
| 43 |
+
const value = readPositiveNumberEnv(name, fallback, env);
|
| 44 |
+
if (!Number.isSafeInteger(value)) {
|
| 45 |
+
throw new Error(`${name} must be a positive integer`);
|
| 46 |
+
}
|
| 47 |
+
return value;
|
| 48 |
+
}
|
| 49 |
+
function readPositiveNumberEnv(name, fallback, env = process.env) {
|
| 50 |
+
const raw = env[name];
|
| 51 |
+
if (raw === undefined || raw === "") {
|
| 52 |
+
return fallback;
|
| 53 |
+
}
|
| 54 |
+
const text = raw.trim();
|
| 55 |
+
if (!/^(?:\d+(?:\.\d+)?|\.\d+)$/u.test(text)) {
|
| 56 |
+
throw new Error(`${name} must be a positive number`);
|
| 57 |
+
}
|
| 58 |
+
const value = Number(text);
|
| 59 |
+
if (!Number.isFinite(value) || value <= 0) {
|
| 60 |
+
throw new Error(`${name} must be a positive number`);
|
| 61 |
+
}
|
| 62 |
+
return value;
|
| 63 |
+
}
|
| 64 |
+
function readNonEmptyEnv(name) {
|
| 65 |
+
const value = process.env[name];
|
| 66 |
+
return value === undefined || value.length === 0 ? null : value;
|
| 67 |
+
}
|
| 68 |
+
function readRequiredPathOption(argv, index, flag) {
|
| 69 |
+
const value = argv[index + 1];
|
| 70 |
+
if (!value || value.startsWith("-")) {
|
| 71 |
+
throw new Error(`${flag} requires a path`);
|
| 72 |
+
}
|
| 73 |
+
return value;
|
| 74 |
+
}
|
| 75 |
+
function parseArgs(argv) {
|
| 76 |
+
const options = {
|
| 77 |
+
jsonPath:
|
| 78 |
+
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_JSON_PATH") ??
|
| 79 |
+
path.join(repoRoot, ".artifacts", "startup-memory", "startup-memory.json"),
|
| 80 |
+
summaryPath:
|
| 81 |
+
readNonEmptyEnv("OPENCLAW_STARTUP_MEMORY_SUMMARY_PATH") ??
|
| 82 |
+
path.join(repoRoot, ".artifacts", "startup-memory", "summary.md"),
|
| 83 |
+
};
|
| 84 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 85 |
+
const arg = argv[index];
|
| 86 |
+
if (arg === undefined) {
|
| 87 |
+
break;
|
| 88 |
+
}
|
| 89 |
+
if (arg === "--json") {
|
| 90 |
+
const value = readRequiredPathOption(argv, index, "--json");
|
| 91 |
+
options.jsonPath = path.resolve(value);
|
| 92 |
+
index += 1;
|
| 93 |
+
continue;
|
| 94 |
+
}
|
| 95 |
+
if (arg === "--summary") {
|
| 96 |
+
const value = readRequiredPathOption(argv, index, "--summary");
|
| 97 |
+
options.summaryPath = path.resolve(value);
|
| 98 |
+
index += 1;
|
| 99 |
+
continue;
|
| 100 |
+
}
|
| 101 |
+
if (arg === "--help") {
|
| 102 |
+
console.log(
|
| 103 |
+
"Usage: node scripts/check-cli-startup-memory.mjs [--json <path>] [--summary <path>]",
|
| 104 |
+
);
|
| 105 |
+
process.exit(0);
|
| 106 |
+
}
|
| 107 |
+
throw new Error(`Unknown option: ${arg}`);
|
| 108 |
+
}
|
| 109 |
+
return options;
|
| 110 |
+
}
|
| 111 |
+
const sameFsObject = (left, right) => left.dev === right.dev && left.ino === right.ino;
|
| 112 |
+
function createReportParents(filePath) {
|
| 113 |
+
const parentPath = path.dirname(filePath);
|
| 114 |
+
const firstCreated = mkdirSync(parentPath, { recursive: true });
|
| 115 |
+
if (!firstCreated) {
|
| 116 |
+
return [];
|
| 117 |
+
}
|
| 118 |
+
const created = [];
|
| 119 |
+
for (let current = parentPath; ; current = path.dirname(current)) {
|
| 120 |
+
created.push({ identity: lstatSync(current, { bigint: true }), path: current });
|
| 121 |
+
if (current === firstCreated) {
|
| 122 |
+
return created;
|
| 123 |
+
}
|
| 124 |
+
}
|
| 125 |
+
}
|
| 126 |
+
function resolveReportLeaf(filePath, unresolvedLinks = new Set()) {
|
| 127 |
+
const physicalParent = realpathSync.native(path.dirname(filePath));
|
| 128 |
+
const leafPath = path.join(physicalParent, path.basename(filePath));
|
| 129 |
+
const identity = lstatSync(leafPath, { bigint: true, throwIfNoEntry: false });
|
| 130 |
+
if (!identity?.isSymbolicLink()) {
|
| 131 |
+
return leafPath;
|
| 132 |
+
}
|
| 133 |
+
if (unresolvedLinks.has(leafPath)) {
|
| 134 |
+
throw Object.assign(new Error("Symlink cycle while resolving report path"), { code: "ELOOP" });
|
| 135 |
+
}
|
| 136 |
+
unresolvedLinks.add(leafPath);
|
| 137 |
+
const target = readlinkSync(leafPath);
|
| 138 |
+
const lastComponent = target.slice(target.lastIndexOf(path.sep) + 1);
|
| 139 |
+
if (lastComponent === "" || lastComponent === "." || lastComponent === "..") {
|
| 140 |
+
throw new Error(REPORT_PATH_FILE_ERROR);
|
| 141 |
+
}
|
| 142 |
+
return resolveReportLeaf(
|
| 143 |
+
path.isAbsolute(target) ? target : `${physicalParent}${path.sep}${target}`,
|
| 144 |
+
unresolvedLinks,
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
function openReport(requestedPath) {
|
| 148 |
+
const resolvedPath = resolveReportLeaf(requestedPath);
|
| 149 |
+
let created = false;
|
| 150 |
+
let fd;
|
| 151 |
+
try {
|
| 152 |
+
fd = openSync(
|
| 153 |
+
resolvedPath,
|
| 154 |
+
fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_RDWR,
|
| 155 |
+
0o600,
|
| 156 |
+
);
|
| 157 |
+
created = true;
|
| 158 |
+
} catch (error) {
|
| 159 |
+
if (error?.code !== "EEXIST") {
|
| 160 |
+
throw error;
|
| 161 |
+
}
|
| 162 |
+
fd = openSync(resolvedPath, fsConstants.O_RDWR | fsConstants.O_NOFOLLOW);
|
| 163 |
+
}
|
| 164 |
+
const identity = fstatSync(fd, { bigint: true });
|
| 165 |
+
if (!identity.isFile()) {
|
| 166 |
+
closeSync(fd);
|
| 167 |
+
throw new Error(REPORT_PATH_FILE_ERROR);
|
| 168 |
+
}
|
| 169 |
+
return { created, fd, identity, path: resolvedPath, requestedPath };
|
| 170 |
+
}
|
| 171 |
+
function assertReportReservation(report) {
|
| 172 |
+
if (sameFsObject(report.identity, statSync(report.requestedPath, { bigint: true }))) {
|
| 173 |
+
return;
|
| 174 |
+
}
|
| 175 |
+
throw new Error(REPORT_PATH_CHANGED_ERROR);
|
| 176 |
+
}
|
| 177 |
+
function cleanupReservation({ reports, parents }) {
|
| 178 |
+
for (const report of reports.toReversed()) {
|
| 179 |
+
try {
|
| 180 |
+
if (!report.created) {
|
| 181 |
+
continue;
|
| 182 |
+
}
|
| 183 |
+
const current = lstatSync(report.path, { bigint: true, throwIfNoEntry: false });
|
| 184 |
+
if (current?.isFile() && sameFsObject(report.identity, current)) {
|
| 185 |
+
unlinkSync(report.path);
|
| 186 |
+
}
|
| 187 |
+
} catch {}
|
| 188 |
+
}
|
| 189 |
+
const deepestFirst = parents.toSorted(
|
| 190 |
+
(left, right) => right.path.split(path.sep).length - left.path.split(path.sep).length,
|
| 191 |
+
);
|
| 192 |
+
for (const parent of deepestFirst) {
|
| 193 |
+
try {
|
| 194 |
+
const current = lstatSync(parent.path, { bigint: true, throwIfNoEntry: false });
|
| 195 |
+
if (current?.isDirectory() && sameFsObject(parent.identity, current)) {
|
| 196 |
+
rmdirSync(parent.path);
|
| 197 |
+
}
|
| 198 |
+
} catch {}
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
function closeReports(reports, index = reports.length - 1) {
|
| 202 |
+
if (index < 0) {
|
| 203 |
+
return;
|
| 204 |
+
}
|
| 205 |
+
try {
|
| 206 |
+
closeSync(reports[index].fd);
|
| 207 |
+
} finally {
|
| 208 |
+
closeReports(reports, index - 1);
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
function reserveReports(options) {
|
| 212 |
+
const parents = [];
|
| 213 |
+
const reports = [];
|
| 214 |
+
try {
|
| 215 |
+
for (const filePath of [options.jsonPath, options.summaryPath]) {
|
| 216 |
+
parents.push(...createReportParents(filePath));
|
| 217 |
+
}
|
| 218 |
+
reports.push(openReport(options.jsonPath));
|
| 219 |
+
reports.push(openReport(options.summaryPath));
|
| 220 |
+
reports.forEach(assertReportReservation);
|
| 221 |
+
if (sameFsObject(reports[0].identity, reports[1].identity)) {
|
| 222 |
+
throw new Error(REPORT_PATH_ALIAS_ERROR);
|
| 223 |
+
}
|
| 224 |
+
return { parents, reports };
|
| 225 |
+
} catch (error) {
|
| 226 |
+
try {
|
| 227 |
+
closeReports(reports);
|
| 228 |
+
} catch {}
|
| 229 |
+
cleanupReservation({ parents, reports });
|
| 230 |
+
throw error;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
function resolveDefaultLimitsMb(platform = process.platform) {
|
| 234 |
+
return {
|
| 235 |
+
// Linux CI is the tight startup regression signal. macOS consistently reports
|
| 236 |
+
// higher RSS for the same launcher path, so keep it supported without hiding
|
| 237 |
+
// Linux help-path regressions.
|
| 238 |
+
help: platform === "darwin" ? 300 : 100,
|
| 239 |
+
// Plugin discovery is heavier than help, but must stay below the doctor/channel
|
| 240 |
+
// runtime graph that an empty metadata-only invocation must not import.
|
| 241 |
+
pluginsList: platform === "darwin" ? 500 : 400,
|
| 242 |
+
// Node 24 status startup reaches ~430 MB on current Linux runner images;
|
| 243 |
+
// retain useful regression headroom without failing on allocator variance.
|
| 244 |
+
statusJson: 450,
|
| 245 |
+
gatewayStatus: 500,
|
| 246 |
+
};
|
| 247 |
+
}
|
| 248 |
+
const DEFAULT_LIMITS_MB = resolveDefaultLimitsMb();
|
| 249 |
+
const cases = [
|
| 250 |
+
{
|
| 251 |
+
id: "help",
|
| 252 |
+
label: "--help",
|
| 253 |
+
args: ["openclaw.mjs", "--help"],
|
| 254 |
+
limitMb: readPositiveNumberEnv("OPENCLAW_STARTUP_MEMORY_HELP_MB", DEFAULT_LIMITS_MB.help),
|
| 255 |
+
},
|
| 256 |
+
{
|
| 257 |
+
id: "pluginsList",
|
| 258 |
+
label: "plugins list --json",
|
| 259 |
+
args: ["openclaw.mjs", "plugins", "list", "--json"],
|
| 260 |
+
limitMb: readPositiveNumberEnv(
|
| 261 |
+
"OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB",
|
| 262 |
+
DEFAULT_LIMITS_MB.pluginsList,
|
| 263 |
+
),
|
| 264 |
+
},
|
| 265 |
+
{
|
| 266 |
+
id: "statusJson",
|
| 267 |
+
label: "status --json",
|
| 268 |
+
args: ["openclaw.mjs", "status", "--json"],
|
| 269 |
+
limitMb: readPositiveNumberEnv(
|
| 270 |
+
"OPENCLAW_STARTUP_MEMORY_STATUS_JSON_MB",
|
| 271 |
+
DEFAULT_LIMITS_MB.statusJson,
|
| 272 |
+
),
|
| 273 |
+
},
|
| 274 |
+
{
|
| 275 |
+
id: "gatewayStatus",
|
| 276 |
+
label: "gateway status",
|
| 277 |
+
args: ["openclaw.mjs", "gateway", "status"],
|
| 278 |
+
limitMb: readPositiveNumberEnv(
|
| 279 |
+
"OPENCLAW_STARTUP_MEMORY_GATEWAY_STATUS_MB",
|
| 280 |
+
DEFAULT_LIMITS_MB.gatewayStatus,
|
| 281 |
+
),
|
| 282 |
+
},
|
| 283 |
+
];
|
| 284 |
+
function formatFixGuidance(testCase, details) {
|
| 285 |
+
const command = `node ${testCase.args.join(" ")}`;
|
| 286 |
+
const guidance = [
|
| 287 |
+
"[startup-memory] Fix guidance",
|
| 288 |
+
`Case: ${testCase.label}`,
|
| 289 |
+
`Command: ${command}`,
|
| 290 |
+
"Next steps:",
|
| 291 |
+
`1. Run \`${command}\` locally on the built tree.`,
|
| 292 |
+
"2. If this is an RSS overage, compare the startup import graph against the last passing commit and look for newly eager imports, bootstrap side effects, or plugin loading on the command path.",
|
| 293 |
+
"3. If this is a non-zero exit, inspect the first transitive import/config error in stderr and fix that root cause before re-checking memory.",
|
| 294 |
+
"LLM prompt:",
|
| 295 |
+
`"OpenClaw startup-memory CI failed for '${testCase.label}'. Analyze this failure, identify the first runtime/import side effect that makes startup heavier or broken, and propose the smallest safe patch. Failure output:\n${details}"`,
|
| 296 |
+
];
|
| 297 |
+
return `${guidance.join("\n")}\n`;
|
| 298 |
+
}
|
| 299 |
+
function formatFailure(testCase, message, details = "") {
|
| 300 |
+
const trimmedDetails = details.trim();
|
| 301 |
+
const sections = [message];
|
| 302 |
+
if (trimmedDetails) {
|
| 303 |
+
sections.push(trimmedDetails);
|
| 304 |
+
}
|
| 305 |
+
sections.push(formatFixGuidance(testCase, trimmedDetails || message));
|
| 306 |
+
return sections.join("\n\n");
|
| 307 |
+
}
|
| 308 |
+
function failResult(report, testCase, error, details = "") {
|
| 309 |
+
return {
|
| 310 |
+
...report,
|
| 311 |
+
status: FAIL,
|
| 312 |
+
error,
|
| 313 |
+
failureMessage: formatFailure(testCase, error, details),
|
| 314 |
+
};
|
| 315 |
+
}
|
| 316 |
+
function parseMaxRssMb(stderr) {
|
| 317 |
+
const matches = [...stderr.matchAll(new RegExp(`^${MAX_RSS_MARKER}(\\d+)\\s*$`, "gm"))];
|
| 318 |
+
const lastMatch = matches.at(-1);
|
| 319 |
+
if (!lastMatch) {
|
| 320 |
+
return null;
|
| 321 |
+
}
|
| 322 |
+
const maxRssKb = Number(lastMatch[1]);
|
| 323 |
+
return Number.isFinite(maxRssKb) && maxRssKb > 0 ? maxRssKb / 1024 : null;
|
| 324 |
+
}
|
| 325 |
+
function formatMb(value) {
|
| 326 |
+
return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(1)} MB` : "n/a";
|
| 327 |
+
}
|
| 328 |
+
function formatCaseCommand(testCase) {
|
| 329 |
+
return `node ${testCase.args.join(" ")}`;
|
| 330 |
+
}
|
| 331 |
+
function nodeImportSpecifierForPath(filePath) {
|
| 332 |
+
return pathToFileURL(filePath).href;
|
| 333 |
+
}
|
| 334 |
+
function buildBenchEnv(homeDir = tmpHome) {
|
| 335 |
+
if (!homeDir) {
|
| 336 |
+
throw new Error("temporary home is not initialized");
|
| 337 |
+
}
|
| 338 |
+
const env = {
|
| 339 |
+
HOME: homeDir,
|
| 340 |
+
USERPROFILE: homeDir,
|
| 341 |
+
XDG_CONFIG_HOME: path.join(homeDir, ".config"),
|
| 342 |
+
XDG_DATA_HOME: path.join(homeDir, ".local", "share"),
|
| 343 |
+
XDG_CACHE_HOME: path.join(homeDir, ".cache"),
|
| 344 |
+
PATH: process.env.PATH ?? "",
|
| 345 |
+
TMPDIR: tmpDir,
|
| 346 |
+
TEMP: tmpDir,
|
| 347 |
+
TMP: tmpDir,
|
| 348 |
+
LANG: process.env.LANG ?? "C.UTF-8",
|
| 349 |
+
TERM: process.env.TERM ?? "dumb",
|
| 350 |
+
};
|
| 351 |
+
if (process.env.LC_ALL) {
|
| 352 |
+
env.LC_ALL = process.env.LC_ALL;
|
| 353 |
+
}
|
| 354 |
+
if (process.env.CI) {
|
| 355 |
+
env.CI = process.env.CI;
|
| 356 |
+
}
|
| 357 |
+
if (process.env.NODE_DISABLE_COMPILE_CACHE) {
|
| 358 |
+
env.NODE_DISABLE_COMPILE_CACHE = process.env.NODE_DISABLE_COMPILE_CACHE;
|
| 359 |
+
} else {
|
| 360 |
+
// Keep the regression check focused on app/runtime startup, not Node's
|
| 361 |
+
// one-shot compile cache overhead, which varies across runner builds.
|
| 362 |
+
env.NODE_DISABLE_COMPILE_CACHE = "1";
|
| 363 |
+
}
|
| 364 |
+
// Keep the benchmark on a single process so RSS reflects the actual command
|
| 365 |
+
// path rather than the warning-suppression respawn wrapper.
|
| 366 |
+
env.OPENCLAW_NO_RESPAWN = "1";
|
| 367 |
+
return env;
|
| 368 |
+
}
|
| 369 |
+
function runCaseSample(testCase, sampleIndex, params = {}) {
|
| 370 |
+
if (!benchEntryPath) {
|
| 371 |
+
throw new Error("bench entry path is not initialized");
|
| 372 |
+
}
|
| 373 |
+
if (!tmpHome) {
|
| 374 |
+
throw new Error("temporary home is not initialized");
|
| 375 |
+
}
|
| 376 |
+
const sampleHome = path.join(tmpHome, "homes", `${testCase.id}-${sampleIndex + 1}`);
|
| 377 |
+
mkdirSync(sampleHome, { recursive: true });
|
| 378 |
+
const env = buildBenchEnv(sampleHome);
|
| 379 |
+
const spawn = params.spawnSync ?? defaultSpawnSync;
|
| 380 |
+
const timeoutMs = params.timeoutMs ?? COMMAND_TIMEOUT_MS;
|
| 381 |
+
const result = spawn(process.execPath, [benchEntryPath, ...testCase.args.slice(1)], {
|
| 382 |
+
cwd: repoRoot,
|
| 383 |
+
env,
|
| 384 |
+
encoding: "utf8",
|
| 385 |
+
maxBuffer: 20 * 1024 * 1024,
|
| 386 |
+
timeout: timeoutMs,
|
| 387 |
+
killSignal: "SIGKILL",
|
| 388 |
+
});
|
| 389 |
+
const stderr = String(result.stderr ?? "");
|
| 390 |
+
const stdout = String(result.stdout ?? "");
|
| 391 |
+
const maxRssMb = parseMaxRssMb(stderr);
|
| 392 |
+
const matrixBootstrapWarning = /matrix: crypto runtime bootstrap failed/i.test(stderr);
|
| 393 |
+
const report = {
|
| 394 |
+
id: testCase.id,
|
| 395 |
+
label: testCase.label,
|
| 396 |
+
command: formatCaseCommand(testCase),
|
| 397 |
+
limitMb: testCase.limitMb,
|
| 398 |
+
rssToleranceMb: STARTUP_MEMORY_RSS_TOLERANCE_MB,
|
| 399 |
+
effectiveLimitMb: testCase.limitMb + STARTUP_MEMORY_RSS_TOLERANCE_MB,
|
| 400 |
+
maxRssMb,
|
| 401 |
+
status: PASS,
|
| 402 |
+
exitCode: result.status,
|
| 403 |
+
signal: result.signal ?? null,
|
| 404 |
+
error: null,
|
| 405 |
+
};
|
| 406 |
+
if (result.error) {
|
| 407 |
+
const timedOut = result.error.code === "ETIMEDOUT";
|
| 408 |
+
const error = timedOut
|
| 409 |
+
? `${testCase.label} timed out after ${timeoutMs}ms`
|
| 410 |
+
: `${testCase.label} failed to start: ${result.error.message}`;
|
| 411 |
+
return failResult(report, testCase, error, stderr.trim() || stdout);
|
| 412 |
+
}
|
| 413 |
+
if (result.status !== 0) {
|
| 414 |
+
const exitDetail = result.status ?? result.signal ?? "unknown";
|
| 415 |
+
const error = `${testCase.label} exited with ${String(exitDetail)}`;
|
| 416 |
+
return failResult(report, testCase, error, stderr.trim() || stdout);
|
| 417 |
+
}
|
| 418 |
+
if (maxRssMb == null) {
|
| 419 |
+
const error = `${testCase.label} did not report max RSS`;
|
| 420 |
+
return failResult(report, testCase, error, stderr);
|
| 421 |
+
}
|
| 422 |
+
if (matrixBootstrapWarning) {
|
| 423 |
+
const error = `${testCase.label} triggered Matrix crypto bootstrap during startup`;
|
| 424 |
+
return failResult(report, testCase, error);
|
| 425 |
+
}
|
| 426 |
+
return report;
|
| 427 |
+
}
|
| 428 |
+
function median(values) {
|
| 429 |
+
const sorted = values.toSorted((left, right) => left - right);
|
| 430 |
+
const middle = sorted.at(Math.floor(sorted.length / 2));
|
| 431 |
+
if (middle === undefined) {
|
| 432 |
+
throw new Error("cannot compute median of an empty sample set");
|
| 433 |
+
}
|
| 434 |
+
return middle;
|
| 435 |
+
}
|
| 436 |
+
function formatRssSamples(samplesMb) {
|
| 437 |
+
return samplesMb.map((value) => value.toFixed(1)).join(", ");
|
| 438 |
+
}
|
| 439 |
+
function runCase(testCase, params = {}) {
|
| 440 |
+
let report = runCaseSample(testCase, 0, params);
|
| 441 |
+
if (report.status !== "pass" || report.maxRssMb == null) {
|
| 442 |
+
return report;
|
| 443 |
+
}
|
| 444 |
+
const samples = [report.maxRssMb];
|
| 445 |
+
// Shared CI runners occasionally produce a single allocator/RSS spike. Independent
|
| 446 |
+
// homes plus a median keep that outlier from masking regressions; two high samples fail.
|
| 447 |
+
for (let sampleIndex = 1; sampleIndex < STARTUP_MEMORY_SAMPLE_COUNT; sampleIndex += 1) {
|
| 448 |
+
const sample = runCaseSample(testCase, sampleIndex, params);
|
| 449 |
+
if (sample.status !== "pass" || sample.maxRssMb == null) {
|
| 450 |
+
return sample;
|
| 451 |
+
}
|
| 452 |
+
samples.push(sample.maxRssMb);
|
| 453 |
+
report = sample;
|
| 454 |
+
}
|
| 455 |
+
const maxRssMb = median(samples);
|
| 456 |
+
const result = { ...report, maxRssMb, rssSamplesMb: samples };
|
| 457 |
+
if (maxRssMb > result.effectiveLimitMb) {
|
| 458 |
+
const error = `${testCase.label} median max RSS ${maxRssMb.toFixed(1)} MB exceeded effective ceiling ${result.effectiveLimitMb} MB (base limit ${result.limitMb} MB; RSS tolerance ${result.rssToleranceMb} MB; samples: ${formatRssSamples(samples)} MB)`;
|
| 459 |
+
return failResult(result, testCase, error);
|
| 460 |
+
}
|
| 461 |
+
console.log(
|
| 462 |
+
`[startup-memory] ${testCase.label}: ${maxRssMb.toFixed(1)} MB median max RSS ` +
|
| 463 |
+
`(base limit ${result.limitMb} MB; RSS tolerance ${result.rssToleranceMb} MB; effective ceiling ${result.effectiveLimitMb} MB; samples ${formatRssSamples(samples)} MB)`,
|
| 464 |
+
);
|
| 465 |
+
return result;
|
| 466 |
+
}
|
| 467 |
+
function writeReport(reports, results) {
|
| 468 |
+
const failed = results.filter((result) => result.status !== "pass");
|
| 469 |
+
const report = {
|
| 470 |
+
generatedAt: new Date().toISOString(),
|
| 471 |
+
platform: process.platform,
|
| 472 |
+
repoRoot,
|
| 473 |
+
status: failed.length === 0 ? "pass" : "fail",
|
| 474 |
+
results: results.map(({ failureMessage: _failureMessage, ...result }) => result),
|
| 475 |
+
};
|
| 476 |
+
const lines = [
|
| 477 |
+
"# OpenClaw Startup Memory",
|
| 478 |
+
"",
|
| 479 |
+
`Generated: ${report.generatedAt}`,
|
| 480 |
+
"",
|
| 481 |
+
`Status: ${report.status}`,
|
| 482 |
+
"",
|
| 483 |
+
...results.map((result) => {
|
| 484 |
+
const samples = result.rssSamplesMb
|
| 485 |
+
? `; samples: ${result.rssSamplesMb.map(formatMb).join(", ")}`
|
| 486 |
+
: "";
|
| 487 |
+
return `- ${result.label}: ${result.status} median max RSS ${formatMb(result.maxRssMb)} (base limit ${formatMb(result.limitMb)}; RSS tolerance ${formatMb(result.rssToleranceMb)}; effective ceiling ${formatMb(result.effectiveLimitMb)}${samples})`;
|
| 488 |
+
}),
|
| 489 |
+
"",
|
| 490 |
+
];
|
| 491 |
+
if (failed.length > 0) {
|
| 492 |
+
lines.push(
|
| 493 |
+
"## Failures",
|
| 494 |
+
"",
|
| 495 |
+
...failed.map((result) => `- ${result.label}: ${result.error ?? "unknown failure"}`),
|
| 496 |
+
"",
|
| 497 |
+
);
|
| 498 |
+
}
|
| 499 |
+
for (const [reservation, contents] of [
|
| 500 |
+
[reports[0], `${JSON.stringify(report, null, 2)}\n`],
|
| 501 |
+
[reports[1], `${lines.join("\n")}\n`],
|
| 502 |
+
]) {
|
| 503 |
+
assertReportReservation(reservation);
|
| 504 |
+
ftruncateSync(reservation.fd, 0);
|
| 505 |
+
writeFileSync(reservation.fd, contents, "utf8");
|
| 506 |
+
assertReportReservation(reservation);
|
| 507 |
+
}
|
| 508 |
+
reports.forEach(assertReportReservation);
|
| 509 |
+
}
|
| 510 |
+
function runStartupMemoryCheck(argv = process.argv.slice(2), params = {}) {
|
| 511 |
+
const platform = params.platform ?? process.platform;
|
| 512 |
+
if (platform !== "linux" && platform !== "darwin") {
|
| 513 |
+
console.log(`[startup-memory] Skipping on unsupported platform: ${platform}`);
|
| 514 |
+
return { skipped: true, results: [] };
|
| 515 |
+
}
|
| 516 |
+
const options = parseArgs(argv);
|
| 517 |
+
const reservation = reserveReports(options);
|
| 518 |
+
const results = [];
|
| 519 |
+
let operationError;
|
| 520 |
+
let published = false;
|
| 521 |
+
let outcome;
|
| 522 |
+
try {
|
| 523 |
+
tmpHome = mkdtempSync(path.join(os.tmpdir(), "openclaw-startup-memory-"));
|
| 524 |
+
benchEntryPath = path.join(tmpHome, "bench-entry.mjs");
|
| 525 |
+
// Run the real launcher in-process so peak RSS is self-reported at exit
|
| 526 |
+
// without --import/--require flags: the entry declines its dist ESM resolve
|
| 527 |
+
// fast path when preload hooks may be registered, so an injected hook would
|
| 528 |
+
// measure a slower non-default resolution configuration instead of what a
|
| 529 |
+
// plain `node openclaw.mjs ...` invocation pays.
|
| 530 |
+
const launcherPath = path.join(repoRoot, "openclaw.mjs");
|
| 531 |
+
writeFileSync(
|
| 532 |
+
benchEntryPath,
|
| 533 |
+
[
|
| 534 |
+
"process.on('exit', () => {",
|
| 535 |
+
" const usage = typeof process.resourceUsage === 'function' ? process.resourceUsage() : null;",
|
| 536 |
+
` if (usage && typeof usage.maxRSS === 'number') console.error('${MAX_RSS_MARKER}' + String(usage.maxRSS));`,
|
| 537 |
+
"});",
|
| 538 |
+
`const launcherPath = ${JSON.stringify(launcherPath)};`,
|
| 539 |
+
"// The launcher and entry expect argv[1] to be the launcher path itself.",
|
| 540 |
+
"process.argv[1] = launcherPath;",
|
| 541 |
+
`await import(${JSON.stringify(nodeImportSpecifierForPath(launcherPath))});`,
|
| 542 |
+
"",
|
| 543 |
+
].join("\n"),
|
| 544 |
+
"utf8",
|
| 545 |
+
);
|
| 546 |
+
for (const testCase of cases) {
|
| 547 |
+
results.push(runCase(testCase, params));
|
| 548 |
+
}
|
| 549 |
+
writeReport(reservation.reports, results);
|
| 550 |
+
published = true;
|
| 551 |
+
const failure = results.find((result) => result.status !== "pass");
|
| 552 |
+
if (failure?.failureMessage) {
|
| 553 |
+
throw new Error(failure.failureMessage);
|
| 554 |
+
}
|
| 555 |
+
outcome = { skipped: false, results };
|
| 556 |
+
} catch (error) {
|
| 557 |
+
operationError = error;
|
| 558 |
+
}
|
| 559 |
+
let cleanupError;
|
| 560 |
+
try {
|
| 561 |
+
closeReports(reservation.reports);
|
| 562 |
+
} catch (error) {
|
| 563 |
+
cleanupError = error;
|
| 564 |
+
}
|
| 565 |
+
if (!published) {
|
| 566 |
+
cleanupReservation(reservation);
|
| 567 |
+
}
|
| 568 |
+
if (tmpHome) {
|
| 569 |
+
try {
|
| 570 |
+
rmSync(tmpHome, { recursive: true, force: true });
|
| 571 |
+
} catch (error) {
|
| 572 |
+
cleanupError ??= error;
|
| 573 |
+
} finally {
|
| 574 |
+
tmpHome = null;
|
| 575 |
+
benchEntryPath = null;
|
| 576 |
+
}
|
| 577 |
+
}
|
| 578 |
+
if (operationError !== undefined) {
|
| 579 |
+
throw operationError instanceof Error
|
| 580 |
+
? operationError
|
| 581 |
+
: new Error("startup memory check failed", { cause: operationError });
|
| 582 |
+
}
|
| 583 |
+
if (cleanupError !== undefined) {
|
| 584 |
+
throw cleanupError instanceof Error
|
| 585 |
+
? cleanupError
|
| 586 |
+
: new Error("startup memory cleanup failed", { cause: cleanupError });
|
| 587 |
+
}
|
| 588 |
+
return outcome;
|
| 589 |
+
}
|
| 590 |
+
/**
|
| 591 |
+
* Test-only access to pure startup memory helper functions.
|
| 592 |
+
*/
|
| 593 |
+
export const testing = {
|
| 594 |
+
cases,
|
| 595 |
+
parseArgs,
|
| 596 |
+
readPositiveIntEnv,
|
| 597 |
+
readPositiveNumberEnv,
|
| 598 |
+
repoRoot,
|
| 599 |
+
resolveDefaultLimitsMb,
|
| 600 |
+
runStartupMemoryCheck,
|
| 601 |
+
sampleCount: STARTUP_MEMORY_SAMPLE_COUNT,
|
| 602 |
+
};
|
| 603 |
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
| 604 |
+
try {
|
| 605 |
+
runStartupMemoryCheck();
|
| 606 |
+
} catch (error) {
|
| 607 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 608 |
+
process.exitCode = 1;
|
| 609 |
+
}
|
| 610 |
+
}
|
scripts/check-composite-action-input-interpolation.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import pathlib
|
| 5 |
+
import re
|
| 6 |
+
import sys
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
INPUT_INTERPOLATION_RE = re.compile(r"\$\{\{\s*inputs\.")
|
| 10 |
+
RUN_LINE_RE = re.compile(r"^(\s*)run:\s*(.*)$")
|
| 11 |
+
USING_COMPOSITE_RE = re.compile(r"^\s*using:\s*composite\s*$", re.MULTILINE)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def indentation(line: str) -> int:
|
| 15 |
+
return len(line) - len(line.lstrip(" "))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def scan_file(path: pathlib.Path) -> list[tuple[int, str]]:
|
| 19 |
+
text = path.read_text(encoding="utf-8")
|
| 20 |
+
if not USING_COMPOSITE_RE.search(text):
|
| 21 |
+
return []
|
| 22 |
+
|
| 23 |
+
lines = text.splitlines()
|
| 24 |
+
violations: list[tuple[int, str]] = []
|
| 25 |
+
line_count = len(lines)
|
| 26 |
+
index = 0
|
| 27 |
+
|
| 28 |
+
while index < line_count:
|
| 29 |
+
line = lines[index]
|
| 30 |
+
match = RUN_LINE_RE.match(line)
|
| 31 |
+
if not match:
|
| 32 |
+
index += 1
|
| 33 |
+
continue
|
| 34 |
+
|
| 35 |
+
run_indent = len(match.group(1))
|
| 36 |
+
run_value = match.group(2).strip()
|
| 37 |
+
line_no = index + 1
|
| 38 |
+
|
| 39 |
+
if run_value and run_value[0] not in ("|", ">"):
|
| 40 |
+
if INPUT_INTERPOLATION_RE.search(run_value):
|
| 41 |
+
violations.append((line_no, line.strip()))
|
| 42 |
+
index += 1
|
| 43 |
+
continue
|
| 44 |
+
|
| 45 |
+
index += 1
|
| 46 |
+
while index < line_count:
|
| 47 |
+
script_line = lines[index]
|
| 48 |
+
if script_line.strip() == "":
|
| 49 |
+
index += 1
|
| 50 |
+
continue
|
| 51 |
+
if indentation(script_line) <= run_indent:
|
| 52 |
+
break
|
| 53 |
+
if INPUT_INTERPOLATION_RE.search(script_line):
|
| 54 |
+
violations.append((index + 1, script_line.strip()))
|
| 55 |
+
index += 1
|
| 56 |
+
|
| 57 |
+
return violations
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def main() -> int:
|
| 61 |
+
root = pathlib.Path(".github/actions")
|
| 62 |
+
files = sorted(root.rglob("action.y*ml"))
|
| 63 |
+
all_violations: list[tuple[pathlib.Path, int, str]] = []
|
| 64 |
+
|
| 65 |
+
for file_path in files:
|
| 66 |
+
for line_no, line in scan_file(file_path):
|
| 67 |
+
all_violations.append((file_path, line_no, line))
|
| 68 |
+
|
| 69 |
+
if all_violations:
|
| 70 |
+
print("Disallowed direct inputs interpolation in composite run blocks:")
|
| 71 |
+
for file_path, line_no, line in all_violations:
|
| 72 |
+
print(f"- {file_path}:{line_no}: {line}")
|
| 73 |
+
print("Use env: and reference shell variables instead.")
|
| 74 |
+
return 1
|
| 75 |
+
|
| 76 |
+
print("No direct inputs interpolation found in composite run blocks.")
|
| 77 |
+
return 0
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
if __name__ == "__main__":
|
| 81 |
+
sys.exit(main())
|
scripts/check-dependency-pins.mts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Audits patched dependency pins for exact versions and drift.
|
| 4 |
+
import { execFileSync } from "node:child_process";
|
| 5 |
+
import fs from "node:fs";
|
| 6 |
+
import path from "node:path";
|
| 7 |
+
import { fileURLToPath } from "node:url";
|
| 8 |
+
import { asRecord } from "@openclaw/normalization-core/record-coerce";
|
| 9 |
+
import YAML from "yaml";
|
| 10 |
+
import { classifyDependencySpec } from "./lib/dependency-spec-policy.mts";
|
| 11 |
+
|
| 12 |
+
const PACKAGE_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies"];
|
| 13 |
+
const WORKSPACE_DEPENDENCY_SECTIONS = ["overrides"];
|
| 14 |
+
const DEFAULT_GIT_TIMEOUT_MS = 60_000;
|
| 15 |
+
|
| 16 |
+
type DependencyPinViolation = {
|
| 17 |
+
file: string;
|
| 18 |
+
section: string;
|
| 19 |
+
name: string;
|
| 20 |
+
spec: unknown;
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
function runGit(cwd: string, args: string[], timeoutMs = DEFAULT_GIT_TIMEOUT_MS): string {
|
| 24 |
+
try {
|
| 25 |
+
return execFileSync("git", args, {
|
| 26 |
+
cwd,
|
| 27 |
+
encoding: "utf8",
|
| 28 |
+
timeout: timeoutMs,
|
| 29 |
+
// A synchronous child that ignores SIGTERM otherwise keeps its parent blocked.
|
| 30 |
+
killSignal: "SIGKILL",
|
| 31 |
+
});
|
| 32 |
+
} catch (error) {
|
| 33 |
+
if (error instanceof Error && "code" in error && error.code === "ETIMEDOUT") {
|
| 34 |
+
throw new Error(
|
| 35 |
+
`dependency pin guard: git ${args.join(" ")} timed out after ${timeoutMs}ms.`,
|
| 36 |
+
{ cause: error },
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
throw error;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function listTrackedPackageJsonFiles(cwd: string, timeoutMs = DEFAULT_GIT_TIMEOUT_MS): string[] {
|
| 44 |
+
return runGit(cwd, ["ls-files", "-z", "--", "*package.json"], timeoutMs)
|
| 45 |
+
.split("\0")
|
| 46 |
+
.filter(Boolean)
|
| 47 |
+
.toSorted((left, right) => left.localeCompare(right));
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
function readJson(filePath: string): Record<string, unknown> {
|
| 51 |
+
return asRecord(JSON.parse(fs.readFileSync(filePath, "utf8")) as unknown);
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
function readTrackedJson(
|
| 55 |
+
cwd: string,
|
| 56 |
+
relativePath: string,
|
| 57 |
+
timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
|
| 58 |
+
): Record<string, unknown> {
|
| 59 |
+
const filePath = path.join(cwd, relativePath);
|
| 60 |
+
if (fs.existsSync(filePath)) {
|
| 61 |
+
return readJson(filePath);
|
| 62 |
+
}
|
| 63 |
+
return asRecord(JSON.parse(runGit(cwd, ["show", `:${relativePath}`], timeoutMs)) as unknown);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function collectPackageJsonViolations(
|
| 67 |
+
cwd: string,
|
| 68 |
+
timeoutMs = DEFAULT_GIT_TIMEOUT_MS,
|
| 69 |
+
): DependencyPinViolation[] {
|
| 70 |
+
const violations: DependencyPinViolation[] = [];
|
| 71 |
+
for (const relativePath of listTrackedPackageJsonFiles(cwd, timeoutMs)) {
|
| 72 |
+
const packageJson = readTrackedJson(cwd, relativePath, timeoutMs);
|
| 73 |
+
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
| 74 |
+
for (const [name, spec] of Object.entries(asRecord(packageJson[section]))) {
|
| 75 |
+
if (!classifyDependencySpec(spec).allowedPinned) {
|
| 76 |
+
violations.push({ file: relativePath, section, name, spec });
|
| 77 |
+
}
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
return violations;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function collectDependencyMapViolations(
|
| 85 |
+
file: string,
|
| 86 |
+
section: string,
|
| 87 |
+
dependencyMap: unknown,
|
| 88 |
+
violations: DependencyPinViolation[],
|
| 89 |
+
): void {
|
| 90 |
+
for (const [name, spec] of Object.entries(asRecord(dependencyMap))) {
|
| 91 |
+
if (section === "overrides" && spec === "-") {
|
| 92 |
+
continue;
|
| 93 |
+
}
|
| 94 |
+
if (!classifyDependencySpec(spec).allowedPinned) {
|
| 95 |
+
violations.push({ file, section, name, spec });
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
function collectWorkspaceViolations(cwd: string): DependencyPinViolation[] {
|
| 101 |
+
const file = "pnpm-workspace.yaml";
|
| 102 |
+
const workspacePath = path.join(cwd, file);
|
| 103 |
+
if (!fs.existsSync(workspacePath)) {
|
| 104 |
+
return [];
|
| 105 |
+
}
|
| 106 |
+
const workspace = asRecord(YAML.parse(fs.readFileSync(workspacePath, "utf8")) as unknown);
|
| 107 |
+
const violations: DependencyPinViolation[] = [];
|
| 108 |
+
for (const section of WORKSPACE_DEPENDENCY_SECTIONS) {
|
| 109 |
+
collectDependencyMapViolations(file, section, workspace?.[section], violations);
|
| 110 |
+
}
|
| 111 |
+
for (const [packageName, extension] of Object.entries(asRecord(workspace.packageExtensions))) {
|
| 112 |
+
collectDependencyMapViolations(
|
| 113 |
+
file,
|
| 114 |
+
`packageExtensions.${packageName}.dependencies`,
|
| 115 |
+
asRecord(extension).dependencies,
|
| 116 |
+
violations,
|
| 117 |
+
);
|
| 118 |
+
}
|
| 119 |
+
return violations;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Collects dependency pin violations for the current workspace.
|
| 124 |
+
*/
|
| 125 |
+
export function collectDependencyPinViolations(
|
| 126 |
+
cwd = process.cwd(),
|
| 127 |
+
{ gitTimeoutMs = DEFAULT_GIT_TIMEOUT_MS } = {},
|
| 128 |
+
): DependencyPinViolation[] {
|
| 129 |
+
return [...collectPackageJsonViolations(cwd, gitTimeoutMs), ...collectWorkspaceViolations(cwd)];
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Builds the full dependency pin audit payload.
|
| 134 |
+
*/
|
| 135 |
+
function collectDependencyPinAudit(cwd = process.cwd()) {
|
| 136 |
+
const packageJsonFiles = listTrackedPackageJsonFiles(cwd);
|
| 137 |
+
let packageSpecCount = 0;
|
| 138 |
+
for (const relativePath of packageJsonFiles) {
|
| 139 |
+
const packageJson = readTrackedJson(cwd, relativePath);
|
| 140 |
+
for (const section of PACKAGE_DEPENDENCY_SECTIONS) {
|
| 141 |
+
packageSpecCount += Object.keys(asRecord(packageJson[section])).length;
|
| 142 |
+
}
|
| 143 |
+
}
|
| 144 |
+
const workspaceViolations = collectWorkspaceViolations(cwd);
|
| 145 |
+
const violations = [...collectPackageJsonViolations(cwd), ...workspaceViolations];
|
| 146 |
+
return {
|
| 147 |
+
packageManifestCount: packageJsonFiles.length,
|
| 148 |
+
packageSpecCount,
|
| 149 |
+
violations,
|
| 150 |
+
};
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
/**
|
| 154 |
+
* Runs the dependency pin check.
|
| 155 |
+
*/
|
| 156 |
+
export async function main() {
|
| 157 |
+
const audit = collectDependencyPinAudit();
|
| 158 |
+
const { violations } = audit;
|
| 159 |
+
if (violations.length === 0) {
|
| 160 |
+
process.stdout.write(
|
| 161 |
+
`PASS direct dependency pin guard: checked ${audit.packageSpecCount} directly declared ` +
|
| 162 |
+
`dependency specs across ${audit.packageManifestCount} tracked package manifests; ` +
|
| 163 |
+
"0 violations.\n",
|
| 164 |
+
);
|
| 165 |
+
return;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
console.error(
|
| 169 |
+
`FAIL direct dependency pin guard: ${violations.length} unpinned directly declared ` +
|
| 170 |
+
"dependency specs found. Direct dependency specs must be pinned exactly outside peer " +
|
| 171 |
+
"dependency contracts:",
|
| 172 |
+
);
|
| 173 |
+
for (const violation of violations) {
|
| 174 |
+
console.error(
|
| 175 |
+
`- ${violation.file}:${violation.section}:${violation.name} -> ${JSON.stringify(violation.spec)}`,
|
| 176 |
+
);
|
| 177 |
+
}
|
| 178 |
+
process.exitCode = 1;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
| 182 |
+
main().catch((error: unknown) => {
|
| 183 |
+
console.error(error);
|
| 184 |
+
process.exit(1);
|
| 185 |
+
});
|
| 186 |
+
}
|
scripts/check-deprecated-jsdoc.mts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Checks deprecated JSDoc blocks for required migration details.
|
| 3 |
+
import fs from "node:fs";
|
| 4 |
+
import { createRequire } from "node:module";
|
| 5 |
+
import path from "node:path";
|
| 6 |
+
import type * as Ts from "typescript";
|
| 7 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 8 |
+
|
| 9 |
+
const require = createRequire(import.meta.url);
|
| 10 |
+
const ts: typeof import("typescript") = require("typescript");
|
| 11 |
+
|
| 12 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 13 |
+
const SCAN_ROOTS = ["src", "extensions", "packages"];
|
| 14 |
+
const SOURCE_FILE_RE = /\.(?:ts|tsx)$/;
|
| 15 |
+
const SKIP_PATH_RE =
|
| 16 |
+
/(?:^|\/)(?:node_modules|dist|build|protocol-generated)(?:\/|$)|(?:\.test|\.spec|\.e2e|\.generated)\.tsx?$/;
|
| 17 |
+
const DEPRECATED_SURFACE_COMMENT_RE =
|
| 18 |
+
/^(?:back-compat alias|backward-compatible alias(?:es)?|deprecated alias|legacy alias|legacy field|legacy:\s|kept for compatibility with existing imports|keep the legacy helper name exported)\b/i;
|
| 19 |
+
|
| 20 |
+
function walk(dir: string, files: string[] = []) {
|
| 21 |
+
if (!fs.existsSync(dir)) {
|
| 22 |
+
return files;
|
| 23 |
+
}
|
| 24 |
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
| 25 |
+
const filePath = path.join(dir, entry.name);
|
| 26 |
+
const relativePath = path.relative(repoRoot, filePath).replaceAll(path.sep, "/");
|
| 27 |
+
if (SKIP_PATH_RE.test(relativePath)) {
|
| 28 |
+
continue;
|
| 29 |
+
}
|
| 30 |
+
if (entry.isDirectory()) {
|
| 31 |
+
walk(filePath, files);
|
| 32 |
+
} else if (SOURCE_FILE_RE.test(entry.name)) {
|
| 33 |
+
files.push(filePath);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
return files;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function leadingCommentText(sourceFile: Ts.SourceFile, node: Ts.Node) {
|
| 40 |
+
return (ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? [])
|
| 41 |
+
.map((range) => sourceFile.text.slice(range.pos, range.end))
|
| 42 |
+
.join("\n");
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
function normalizeCommentText(comment: string) {
|
| 46 |
+
return comment
|
| 47 |
+
.replace(/\/\*\*?/g, "")
|
| 48 |
+
.replace(/\*\//g, "")
|
| 49 |
+
.split("\n")
|
| 50 |
+
.map((line) => line.replace(/^\s*(?:\*|\/\/)\s?/, "").trim())
|
| 51 |
+
.filter(Boolean)
|
| 52 |
+
.join(" ");
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function lineOf(sourceFile: Ts.SourceFile, node: Ts.Node) {
|
| 56 |
+
return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
function isExported(node: Ts.Node) {
|
| 60 |
+
const modifiers = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
| 61 |
+
return (
|
| 62 |
+
modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) ||
|
| 63 |
+
node.parent?.kind === ts.SyntaxKind.SourceFile
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
type InspectedNode = Ts.Declaration | Ts.VariableStatement;
|
| 68 |
+
type Violation = { filePath: string; line: number; name: string };
|
| 69 |
+
|
| 70 |
+
function symbolName(node: InspectedNode) {
|
| 71 |
+
const declaration = ts.isVariableStatement(node) ? node.declarationList.declarations[0] : node;
|
| 72 |
+
return ts.getNameOfDeclaration(declaration)?.getText() ?? "<anonymous>";
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
function shouldInspectNode(node: Ts.Node): node is InspectedNode {
|
| 76 |
+
if (
|
| 77 |
+
ts.isFunctionDeclaration(node) ||
|
| 78 |
+
ts.isClassDeclaration(node) ||
|
| 79 |
+
ts.isInterfaceDeclaration(node) ||
|
| 80 |
+
ts.isTypeAliasDeclaration(node) ||
|
| 81 |
+
ts.isEnumDeclaration(node) ||
|
| 82 |
+
ts.isVariableStatement(node)
|
| 83 |
+
) {
|
| 84 |
+
return isExported(node);
|
| 85 |
+
}
|
| 86 |
+
return (
|
| 87 |
+
ts.isPropertySignature(node) ||
|
| 88 |
+
ts.isMethodSignature(node) ||
|
| 89 |
+
ts.isPropertyDeclaration(node) ||
|
| 90 |
+
ts.isEnumMember(node)
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function collectViolations(filePath: string) {
|
| 95 |
+
const sourceText = fs.readFileSync(filePath, "utf8");
|
| 96 |
+
const sourceFile = ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.Latest, true);
|
| 97 |
+
const violations: Violation[] = [];
|
| 98 |
+
|
| 99 |
+
function visit(node: Ts.Node) {
|
| 100 |
+
if (shouldInspectNode(node)) {
|
| 101 |
+
const comment = leadingCommentText(sourceFile, node);
|
| 102 |
+
const normalizedComment = normalizeCommentText(comment);
|
| 103 |
+
if (
|
| 104 |
+
normalizedComment &&
|
| 105 |
+
DEPRECATED_SURFACE_COMMENT_RE.test(normalizedComment) &&
|
| 106 |
+
!/@deprecated\b/.test(comment)
|
| 107 |
+
) {
|
| 108 |
+
violations.push({
|
| 109 |
+
line: lineOf(sourceFile, node),
|
| 110 |
+
name: symbolName(node),
|
| 111 |
+
filePath: path.relative(repoRoot, filePath).replaceAll(path.sep, "/"),
|
| 112 |
+
});
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
ts.forEachChild(node, visit);
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
visit(sourceFile);
|
| 119 |
+
return violations;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
const violations = SCAN_ROOTS.flatMap((root) =>
|
| 123 |
+
walk(path.join(repoRoot, root)).flatMap(collectViolations),
|
| 124 |
+
);
|
| 125 |
+
|
| 126 |
+
if (violations.length > 0) {
|
| 127 |
+
console.error("Deprecated JSDoc guard failed:");
|
| 128 |
+
for (const violation of violations) {
|
| 129 |
+
console.error(`- ${violation.filePath}:${violation.line} ${violation.name}`);
|
| 130 |
+
}
|
| 131 |
+
console.error(
|
| 132 |
+
"Add an @deprecated JSDoc tag or reword the comment if the symbol is not deprecated.",
|
| 133 |
+
);
|
| 134 |
+
process.exitCode = 1;
|
| 135 |
+
} else {
|
| 136 |
+
console.log("deprecated JSDoc guard passed");
|
| 137 |
+
}
|
scripts/check-docker-e2e-boundaries.mts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Cheap guard for Docker E2E test boundaries.
|
| 3 |
+
// Docker E2E must test packaged npm tarballs and package-installed images, not
|
| 4 |
+
// the source checkout copied or mounted as the app under test.
|
| 5 |
+
import fs from "node:fs";
|
| 6 |
+
import path from "node:path";
|
| 7 |
+
import packageJson from "../package.json" with { type: "json" };
|
| 8 |
+
import { laneResources, lanesNeedOpenClawPackage, laneWeight } from "./lib/docker-e2e-plan.mts";
|
| 9 |
+
import {
|
| 10 |
+
allReleasePathLanes,
|
| 11 |
+
mainLanes,
|
| 12 |
+
publicInstallerLanes,
|
| 13 |
+
tailLanes,
|
| 14 |
+
} from "./lib/docker-e2e-scenarios.mts";
|
| 15 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 16 |
+
const ROOT_DIR = resolveRepoRoot(import.meta.url);
|
| 17 |
+
const errors: string[] = [];
|
| 18 |
+
const packageScripts = new Set(Object.keys(packageJson.scripts ?? {}));
|
| 19 |
+
// These lanes prove package-installed surfaces against live auth, so they
|
| 20 |
+
// intentionally need both live credentials and a package-backed image.
|
| 21 |
+
const livePackageBackedLanes = new Set([
|
| 22 |
+
"install-e2e-anthropic",
|
| 23 |
+
"install-e2e-openai",
|
| 24 |
+
"live-anthropic-cache",
|
| 25 |
+
"live-codex-npm-plugin",
|
| 26 |
+
"live-mcp-code-mode-gateway",
|
| 27 |
+
"live-plugin-tool",
|
| 28 |
+
"npm-telegram-live",
|
| 29 |
+
"openai-chat-tools",
|
| 30 |
+
"openwebui",
|
| 31 |
+
]);
|
| 32 |
+
// These lanes intentionally build a focused source-checkout image instead of
|
| 33 |
+
// consuming the shared package E2E images.
|
| 34 |
+
const sourceCheckoutImageLanes = new Set([
|
| 35 |
+
"docker-selected-plugins",
|
| 36 |
+
"plugin-binding-command-escape",
|
| 37 |
+
]);
|
| 38 |
+
|
| 39 |
+
function readText(relativePath: string) {
|
| 40 |
+
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function walk(dir: string, out: string[] = []) {
|
| 44 |
+
for (const entry of fs.readdirSync(path.join(ROOT_DIR, dir), { withFileTypes: true })) {
|
| 45 |
+
const relativePath = path.join(dir, entry.name);
|
| 46 |
+
if (entry.isDirectory()) {
|
| 47 |
+
walk(relativePath, out);
|
| 48 |
+
} else {
|
| 49 |
+
out.push(relativePath);
|
| 50 |
+
}
|
| 51 |
+
}
|
| 52 |
+
return out;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
function findRelativeModuleSpecifiers(text: string) {
|
| 56 |
+
const specifiers = new Set<string>();
|
| 57 |
+
for (const pattern of [
|
| 58 |
+
/(?:\bfrom\s*|\bimport\s*\()\s*["']([^"']+)["']/gu,
|
| 59 |
+
/\bimport\s*["']([^"']+)["']/gu,
|
| 60 |
+
]) {
|
| 61 |
+
for (const match of text.matchAll(pattern)) {
|
| 62 |
+
const specifier = match[1];
|
| 63 |
+
if (specifier?.startsWith(".")) {
|
| 64 |
+
specifiers.add(specifier);
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
return [...specifiers];
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
function isPathWithin(parent: string, candidate: string) {
|
| 72 |
+
const relative = path.relative(parent, candidate);
|
| 73 |
+
return (
|
| 74 |
+
relative === "" ||
|
| 75 |
+
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
|
| 76 |
+
);
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
for (const relativePath of walk("scripts/e2e")) {
|
| 80 |
+
if (!/\.(?:sh|ts|mts|mjs|js)$/u.test(relativePath)) {
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
const text = readText(relativePath);
|
| 84 |
+
const sourceImport = findRelativeModuleSpecifiers(text).find((specifier) => {
|
| 85 |
+
const resolved = path.resolve(ROOT_DIR, path.dirname(relativePath), specifier);
|
| 86 |
+
return isPathWithin(path.join(ROOT_DIR, "src"), resolved);
|
| 87 |
+
});
|
| 88 |
+
if (sourceImport) {
|
| 89 |
+
errors.push(
|
| 90 |
+
`${relativePath}: Docker E2E harness must import package exports, not ${sourceImport}`,
|
| 91 |
+
);
|
| 92 |
+
}
|
| 93 |
+
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
|
| 94 |
+
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const dockerfile = readText("scripts/e2e/Dockerfile");
|
| 99 |
+
if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) {
|
| 100 |
+
errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app");
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
function validateUniqueLanes(label: string, lanes: readonly (typeof mainLanes)[number][]) {
|
| 104 |
+
const seen = new Set<string>();
|
| 105 |
+
for (const lane of lanes) {
|
| 106 |
+
if (seen.has(lane.name)) {
|
| 107 |
+
errors.push(`${label}: duplicate Docker E2E lane '${lane.name}'`);
|
| 108 |
+
}
|
| 109 |
+
seen.add(lane.name);
|
| 110 |
+
}
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
function validateLane(label: string, lane: (typeof mainLanes)[number]) {
|
| 114 |
+
const resources = laneResources(lane);
|
| 115 |
+
const sourceCheckoutImageLane = sourceCheckoutImageLanes.has(lane.name);
|
| 116 |
+
const needsPackage = lanesNeedOpenClawPackage([lane]);
|
| 117 |
+
const localImageBuild = sourceCheckoutImageLane || (needsPackage && !lane.e2eImageKind);
|
| 118 |
+
if (!lane.name || typeof lane.name !== "string") {
|
| 119 |
+
errors.push(`${label}: Docker E2E lane is missing a string name`);
|
| 120 |
+
}
|
| 121 |
+
if (!lane.command || typeof lane.command !== "string") {
|
| 122 |
+
errors.push(`${label}: Docker E2E lane '${lane.name}' is missing a string command`);
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
if (lane.e2eImageKind && lane.e2eImageKind !== "bare" && lane.e2eImageKind !== "functional") {
|
| 126 |
+
const invalidImageKind =
|
| 127 |
+
typeof lane.e2eImageKind === "string"
|
| 128 |
+
? lane.e2eImageKind
|
| 129 |
+
: (JSON.stringify(lane.e2eImageKind) ?? "<invalid>");
|
| 130 |
+
errors.push(
|
| 131 |
+
`${label}: Docker E2E lane '${lane.name}' has invalid image kind '${invalidImageKind}'`,
|
| 132 |
+
);
|
| 133 |
+
}
|
| 134 |
+
if (lane.live && needsPackage && !livePackageBackedLanes.has(lane.name)) {
|
| 135 |
+
errors.push(`${label}: live Docker E2E lane '${lane.name}' must not require a package image`);
|
| 136 |
+
}
|
| 137 |
+
if (!lane.live && !needsPackage && !sourceCheckoutImageLane) {
|
| 138 |
+
errors.push(
|
| 139 |
+
`${label}: package Docker E2E lane '${lane.name}' must request package preparation`,
|
| 140 |
+
);
|
| 141 |
+
}
|
| 142 |
+
if (localImageBuild && !/\bOPENCLAW_SKIP_DOCKER_BUILD=0\b/u.test(lane.command)) {
|
| 143 |
+
errors.push(
|
| 144 |
+
`${label}: locally built Docker E2E lane '${lane.name}' must force a local image build`,
|
| 145 |
+
);
|
| 146 |
+
}
|
| 147 |
+
if (laneWeight(lane) < 1) {
|
| 148 |
+
errors.push(`${label}: Docker E2E lane '${lane.name}' must have positive weight`);
|
| 149 |
+
}
|
| 150 |
+
if (!resources.includes("docker")) {
|
| 151 |
+
errors.push(`${label}: Docker E2E lane '${lane.name}' must include the docker resource`);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
for (const match of lane.command.matchAll(/\bpnpm\s+([^\s]+)/gu)) {
|
| 155 |
+
const script = match[1];
|
| 156 |
+
if (script && !packageScripts.has(script)) {
|
| 157 |
+
errors.push(
|
| 158 |
+
`${label}: Docker E2E lane '${lane.name}' references missing package script '${script}'`,
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true });
|
| 165 |
+
for (const [label, lanes] of [
|
| 166 |
+
["release-path", releasePathLanes],
|
| 167 |
+
["public-installer", publicInstallerLanes],
|
| 168 |
+
["main", mainLanes],
|
| 169 |
+
["tail", tailLanes],
|
| 170 |
+
] as const) {
|
| 171 |
+
validateUniqueLanes(label, lanes);
|
| 172 |
+
for (const lane of lanes) {
|
| 173 |
+
validateLane(label, lane);
|
| 174 |
+
}
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
if (errors.length > 0) {
|
| 178 |
+
console.error(errors.join("\n"));
|
| 179 |
+
process.exit(1);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
console.log("Docker E2E package boundary/catalog guard passed.");
|
scripts/check-docs-config-examples.mjs
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
import { runTsxCliShim } from "./lib/tsx-cli-shim.mjs";
|
| 3 |
+
|
| 4 |
+
await runTsxCliShim(import.meta.url, {
|
| 5 |
+
implementation: "./check-docs-config-examples.mts",
|
| 6 |
+
failureTool: "check-docs-config-examples",
|
| 7 |
+
});
|
scripts/check-docs-mdx.mts
ADDED
|
@@ -0,0 +1,477 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Validates docs MDX files for syntax and repository-specific conventions.
|
| 4 |
+
|
| 5 |
+
import { createHash } from "node:crypto";
|
| 6 |
+
import fs from "node:fs";
|
| 7 |
+
import path from "node:path";
|
| 8 |
+
import { fileURLToPath } from "node:url";
|
| 9 |
+
import { compile } from "@mdx-js/mdx";
|
| 10 |
+
import { requireOptionArgument } from "./lib/arg-utils.runtime.mjs";
|
| 11 |
+
import {
|
| 12 |
+
checkMintlifyAccordionIndentation,
|
| 13 |
+
MINTLIFY_ACCORDION_INDENT_MESSAGE,
|
| 14 |
+
} from "./lib/mintlify-accordion.mjs";
|
| 15 |
+
|
| 16 |
+
type DocsCheckError = {
|
| 17 |
+
type: string;
|
| 18 |
+
file: string;
|
| 19 |
+
message: string;
|
| 20 |
+
line?: number;
|
| 21 |
+
column?: number;
|
| 22 |
+
};
|
| 23 |
+
|
| 24 |
+
function validationCache(cacheFile: string) {
|
| 25 |
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
| 26 |
+
const fingerprint = createHash("sha256").update(
|
| 27 |
+
JSON.stringify([process.version, process.platform, process.arch, process.execArgv]),
|
| 28 |
+
);
|
| 29 |
+
// The publish workflow installs with npm ci before invoking this opt-in cache.
|
| 30 |
+
// Include the installed lock as well as the requested lock; never infer a
|
| 31 |
+
// successful check from a source commit or translation's source_hash.
|
| 32 |
+
for (const input of [
|
| 33 |
+
fileURLToPath(import.meta.url),
|
| 34 |
+
fileURLToPath(new URL("./lib/arg-utils.runtime.mjs", import.meta.url)),
|
| 35 |
+
fileURLToPath(new URL("./lib/mintlify-accordion.mjs", import.meta.url)),
|
| 36 |
+
path.join(root, "package.json"),
|
| 37 |
+
path.join(root, "package-lock.json"),
|
| 38 |
+
path.join(root, "node_modules", ".package-lock.json"),
|
| 39 |
+
]) {
|
| 40 |
+
fingerprint.update(fs.readFileSync(input)).update("\0");
|
| 41 |
+
}
|
| 42 |
+
const key = fingerprint.digest("hex");
|
| 43 |
+
let previous = new Map<string, string>();
|
| 44 |
+
try {
|
| 45 |
+
const saved = JSON.parse(fs.readFileSync(cacheFile, "utf8"));
|
| 46 |
+
if (
|
| 47 |
+
saved.key === key &&
|
| 48 |
+
saved.files &&
|
| 49 |
+
typeof saved.files === "object" &&
|
| 50 |
+
!Array.isArray(saved.files)
|
| 51 |
+
) {
|
| 52 |
+
const entries: [string, string][] = [];
|
| 53 |
+
for (const [file, digest] of Object.entries(saved.files)) {
|
| 54 |
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) {
|
| 55 |
+
throw new Error("Invalid validation cache entry");
|
| 56 |
+
}
|
| 57 |
+
entries.push([file, digest]);
|
| 58 |
+
}
|
| 59 |
+
previous = new Map(entries);
|
| 60 |
+
}
|
| 61 |
+
} catch {
|
| 62 |
+
// Missing, obsolete, or corrupt disposable build artifacts are cold checks.
|
| 63 |
+
}
|
| 64 |
+
const checked = new Map<string, string>();
|
| 65 |
+
return {
|
| 66 |
+
root,
|
| 67 |
+
previous,
|
| 68 |
+
checked,
|
| 69 |
+
save() {
|
| 70 |
+
fs.mkdirSync(path.dirname(path.resolve(cacheFile)), { recursive: true });
|
| 71 |
+
const temporary = `${cacheFile}.${process.pid}.tmp`;
|
| 72 |
+
fs.writeFileSync(
|
| 73 |
+
temporary,
|
| 74 |
+
`${JSON.stringify({ key, files: Object.fromEntries(checked) })}\n`,
|
| 75 |
+
);
|
| 76 |
+
fs.renameSync(temporary, cacheFile);
|
| 77 |
+
},
|
| 78 |
+
};
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
const MINTLIFY_LANGUAGE_CODES = new Set([
|
| 82 |
+
"en",
|
| 83 |
+
"cn",
|
| 84 |
+
"zh",
|
| 85 |
+
"zh-Hans",
|
| 86 |
+
"zh-Hant",
|
| 87 |
+
"es",
|
| 88 |
+
"fr",
|
| 89 |
+
"fr-CA",
|
| 90 |
+
"fr-ca",
|
| 91 |
+
"ja",
|
| 92 |
+
"jp",
|
| 93 |
+
"ja-jp",
|
| 94 |
+
"pt",
|
| 95 |
+
"pt-BR",
|
| 96 |
+
"de",
|
| 97 |
+
"ko",
|
| 98 |
+
"it",
|
| 99 |
+
"ru",
|
| 100 |
+
"ro",
|
| 101 |
+
"cs",
|
| 102 |
+
"id",
|
| 103 |
+
"ar",
|
| 104 |
+
"tr",
|
| 105 |
+
"hi",
|
| 106 |
+
"sv",
|
| 107 |
+
"no",
|
| 108 |
+
"lv",
|
| 109 |
+
"nl",
|
| 110 |
+
"uk",
|
| 111 |
+
"vi",
|
| 112 |
+
"pl",
|
| 113 |
+
"uz",
|
| 114 |
+
"he",
|
| 115 |
+
"ca",
|
| 116 |
+
"fi",
|
| 117 |
+
"hu",
|
| 118 |
+
]);
|
| 119 |
+
|
| 120 |
+
const POISON_TEXT_PATTERNS = [
|
| 121 |
+
{
|
| 122 |
+
pattern: /\banalysis\s+to=functions\./iu,
|
| 123 |
+
message: "Leaked tool-call channel marker.",
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
pattern: /\b(?:commentary|final)\s+to=functions\./iu,
|
| 127 |
+
message: "Leaked tool-call channel marker.",
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
pattern: /\bfunctions\.(?:read|write|exec|search|run)\b/iu,
|
| 131 |
+
message: "Leaked internal tool name.",
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
pattern: /\b[A-Za-z_\u3400-\u9fff][\w\u3400-\u9fff-]*_input=\{/u,
|
| 135 |
+
message: "Leaked tool-call input payload.",
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
pattern: /<\/?openclaw_docs_i18n_input>/iu,
|
| 139 |
+
message: "Leaked docs i18n prompt wrapper.",
|
| 140 |
+
},
|
| 141 |
+
{
|
| 142 |
+
pattern: /\/home\/runner\/work\//u,
|
| 143 |
+
message: "Leaked GitHub Actions workspace path.",
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
pattern: /彩神马争霸/u,
|
| 147 |
+
message: "Known spam/gambling text from a poisoned translation.",
|
| 148 |
+
},
|
| 149 |
+
];
|
| 150 |
+
|
| 151 |
+
function parsePositiveIntegerArg(raw: string | undefined, label: string): number {
|
| 152 |
+
const text = raw?.trim() ?? "";
|
| 153 |
+
if (!/^\d+$/u.test(text)) {
|
| 154 |
+
throw new Error(`${label} must be a positive integer`);
|
| 155 |
+
}
|
| 156 |
+
const value = Number(text);
|
| 157 |
+
if (!Number.isSafeInteger(value) || value < 1) {
|
| 158 |
+
throw new Error(`${label} must be a positive integer`);
|
| 159 |
+
}
|
| 160 |
+
return value;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Parses docs MDX check arguments.
|
| 165 |
+
*/
|
| 166 |
+
export function parseArgs(argv: string[]) {
|
| 167 |
+
const roots: string[] = [];
|
| 168 |
+
let jsonOut = "";
|
| 169 |
+
let maxErrors = 50;
|
| 170 |
+
let cacheFile = "";
|
| 171 |
+
|
| 172 |
+
for (let index = 0; index < argv.length; index += 1) {
|
| 173 |
+
const part = argv[index];
|
| 174 |
+
if (part === undefined) {
|
| 175 |
+
continue;
|
| 176 |
+
}
|
| 177 |
+
if (part === "--json-out") {
|
| 178 |
+
jsonOut = requireOptionArgument(argv, index, "--json-out");
|
| 179 |
+
index += 1;
|
| 180 |
+
continue;
|
| 181 |
+
}
|
| 182 |
+
if (part === "--cache-file") {
|
| 183 |
+
cacheFile = requireOptionArgument(argv, index, "--cache-file");
|
| 184 |
+
index += 1;
|
| 185 |
+
continue;
|
| 186 |
+
}
|
| 187 |
+
if (part === "--max-errors") {
|
| 188 |
+
maxErrors = parsePositiveIntegerArg(
|
| 189 |
+
requireOptionArgument(argv, index, "--max-errors"),
|
| 190 |
+
"--max-errors",
|
| 191 |
+
);
|
| 192 |
+
index += 1;
|
| 193 |
+
continue;
|
| 194 |
+
}
|
| 195 |
+
if (part.startsWith("--")) {
|
| 196 |
+
throw new Error(`unknown arg: ${part}`);
|
| 197 |
+
}
|
| 198 |
+
roots.push(part);
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
roots: roots.length ? roots : ["docs"],
|
| 203 |
+
jsonOut,
|
| 204 |
+
maxErrors,
|
| 205 |
+
...(cacheFile ? { cacheFile } : {}),
|
| 206 |
+
};
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
function walkMarkdownFiles(entryPath: string, out: string[] = []): string[] {
|
| 210 |
+
const stat = fs.statSync(entryPath);
|
| 211 |
+
if (stat.isFile()) {
|
| 212 |
+
if (/\.mdx?$/i.test(entryPath)) {
|
| 213 |
+
out.push(path.resolve(entryPath));
|
| 214 |
+
}
|
| 215 |
+
return out;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) {
|
| 219 |
+
if (entry.name === "node_modules" || entry.name === ".git") {
|
| 220 |
+
continue;
|
| 221 |
+
}
|
| 222 |
+
walkMarkdownFiles(path.join(entryPath, entry.name), out);
|
| 223 |
+
}
|
| 224 |
+
return out;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
function stripFrontmatter(raw: string): string {
|
| 228 |
+
if (!raw.startsWith("---\n") && !raw.startsWith("---\r\n")) {
|
| 229 |
+
return raw;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
const lines = raw.split(/\r?\n/u);
|
| 233 |
+
for (let index = 1; index < lines.length; index += 1) {
|
| 234 |
+
if (lines[index] === "---" || lines[index] === "...") {
|
| 235 |
+
// Preserve source line numbers for both MDX and component diagnostics.
|
| 236 |
+
return lines.fill("", 0, index + 1).join("\n");
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
return raw;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function errorField(error: unknown, key: string): unknown {
|
| 243 |
+
return error && typeof error === "object" && key in error
|
| 244 |
+
? error[key as keyof typeof error]
|
| 245 |
+
: undefined;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function formatMdxError(filePath: string, error: unknown): DocsCheckError {
|
| 249 |
+
const reason = errorField(error, "reason");
|
| 250 |
+
const message = errorField(error, "message");
|
| 251 |
+
const line = errorField(error, "line");
|
| 252 |
+
const column = errorField(error, "column");
|
| 253 |
+
return {
|
| 254 |
+
type: "mdx",
|
| 255 |
+
file: filePath,
|
| 256 |
+
...(typeof line === "number" ? { line } : {}),
|
| 257 |
+
...(typeof column === "number" ? { column } : {}),
|
| 258 |
+
message: String(reason ?? message ?? error).split("\n")[0] ?? "",
|
| 259 |
+
};
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
function checkMintlifyMdxStructure(filePath: string, raw: string): DocsCheckError[] {
|
| 263 |
+
return checkMintlifyAccordionIndentation(stripFrontmatter(raw)).map((error) => ({
|
| 264 |
+
type: "mintlify-mdx",
|
| 265 |
+
file: filePath,
|
| 266 |
+
line: error.line,
|
| 267 |
+
column: error.column,
|
| 268 |
+
message: MINTLIFY_ACCORDION_INDENT_MESSAGE,
|
| 269 |
+
}));
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
function lineColumnForIndex(raw: string, offset: number): { line: number; column: number } {
|
| 273 |
+
const prefix = raw.slice(0, offset);
|
| 274 |
+
const lines = prefix.split(/\r?\n/u);
|
| 275 |
+
return {
|
| 276 |
+
line: lines.length,
|
| 277 |
+
column: (lines.at(-1) ?? "").length + 1,
|
| 278 |
+
};
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function checkPoisonText(filePath: string, raw: string): DocsCheckError[] {
|
| 282 |
+
const errors: DocsCheckError[] = [];
|
| 283 |
+
for (const { pattern, message } of POISON_TEXT_PATTERNS) {
|
| 284 |
+
const match = pattern.exec(raw);
|
| 285 |
+
if (!match) {
|
| 286 |
+
continue;
|
| 287 |
+
}
|
| 288 |
+
const location = lineColumnForIndex(raw, match.index);
|
| 289 |
+
errors.push({
|
| 290 |
+
type: "poison-text",
|
| 291 |
+
file: filePath,
|
| 292 |
+
line: location.line,
|
| 293 |
+
column: location.column,
|
| 294 |
+
message,
|
| 295 |
+
});
|
| 296 |
+
}
|
| 297 |
+
return errors;
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
async function checkMdxFile(filePath: string, raw: string): Promise<DocsCheckError[]> {
|
| 301 |
+
const poisonErrors = checkPoisonText(filePath, raw);
|
| 302 |
+
if (poisonErrors.length > 0) {
|
| 303 |
+
return poisonErrors;
|
| 304 |
+
}
|
| 305 |
+
const structureErrors = checkMintlifyMdxStructure(filePath, raw);
|
| 306 |
+
if (structureErrors.length > 0) {
|
| 307 |
+
return structureErrors;
|
| 308 |
+
}
|
| 309 |
+
await compile({ path: filePath, value: stripFrontmatter(raw) });
|
| 310 |
+
return [];
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
function findDocsJsonPaths(roots: string[]): string[] {
|
| 314 |
+
const paths = new Set<string>();
|
| 315 |
+
for (const root of roots) {
|
| 316 |
+
const absolute = path.resolve(root);
|
| 317 |
+
if (!fs.existsSync(absolute)) {
|
| 318 |
+
continue;
|
| 319 |
+
}
|
| 320 |
+
const stat = fs.statSync(absolute);
|
| 321 |
+
if (stat.isFile() && path.basename(absolute) === "docs.json") {
|
| 322 |
+
paths.add(absolute);
|
| 323 |
+
continue;
|
| 324 |
+
}
|
| 325 |
+
if (stat.isDirectory()) {
|
| 326 |
+
const docsJsonPath = path.join(absolute, "docs.json");
|
| 327 |
+
if (fs.existsSync(docsJsonPath)) {
|
| 328 |
+
paths.add(docsJsonPath);
|
| 329 |
+
}
|
| 330 |
+
}
|
| 331 |
+
}
|
| 332 |
+
return [...paths];
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
function collectNavigationLanguages(value: unknown, out: string[] = []): string[] {
|
| 336 |
+
if (Array.isArray(value)) {
|
| 337 |
+
for (const item of value) {
|
| 338 |
+
collectNavigationLanguages(item, out);
|
| 339 |
+
}
|
| 340 |
+
return out;
|
| 341 |
+
}
|
| 342 |
+
if (!value || typeof value !== "object") {
|
| 343 |
+
return out;
|
| 344 |
+
}
|
| 345 |
+
const record = value as Record<string, unknown>;
|
| 346 |
+
if (typeof record.language === "string") {
|
| 347 |
+
out.push(record.language);
|
| 348 |
+
}
|
| 349 |
+
for (const child of Object.values(record)) {
|
| 350 |
+
if (child && typeof child === "object") {
|
| 351 |
+
collectNavigationLanguages(child, out);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
return out;
|
| 355 |
+
}
|
| 356 |
+
|
| 357 |
+
function checkDocsJson(filePath: string): DocsCheckError[] {
|
| 358 |
+
const errors: DocsCheckError[] = [];
|
| 359 |
+
let data: unknown;
|
| 360 |
+
try {
|
| 361 |
+
data = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
| 362 |
+
} catch (error) {
|
| 363 |
+
return [
|
| 364 |
+
{
|
| 365 |
+
type: "docs-json",
|
| 366 |
+
file: filePath,
|
| 367 |
+
message: `Invalid JSON: ${String(errorField(error, "message") ?? error)}`,
|
| 368 |
+
},
|
| 369 |
+
];
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
const navigation =
|
| 373 |
+
data && typeof data === "object" && "navigation" in data ? data.navigation : undefined;
|
| 374 |
+
const languages = collectNavigationLanguages(navigation);
|
| 375 |
+
for (const language of languages) {
|
| 376 |
+
if (!MINTLIFY_LANGUAGE_CODES.has(language)) {
|
| 377 |
+
errors.push({
|
| 378 |
+
type: "docs-json",
|
| 379 |
+
file: filePath,
|
| 380 |
+
message: `Unsupported Mintlify navigation language: ${language}`,
|
| 381 |
+
});
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
return errors;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
function relativize(root: string, filePath: string): string {
|
| 388 |
+
const relative = path.relative(root, filePath);
|
| 389 |
+
return relative && !relative.startsWith("..") ? relative : filePath;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
async function main(): Promise<void> {
|
| 393 |
+
const startedAt = Date.now();
|
| 394 |
+
const args = parseArgs(process.argv.slice(2));
|
| 395 |
+
const cwd = process.cwd();
|
| 396 |
+
const roots = args.roots.map((root) => path.resolve(root));
|
| 397 |
+
const cache = args.cacheFile ? validationCache(args.cacheFile) : undefined;
|
| 398 |
+
let cacheHits = 0;
|
| 399 |
+
const files = [
|
| 400 |
+
...new Set(
|
| 401 |
+
roots.flatMap((root) => {
|
| 402 |
+
if (!fs.existsSync(root)) {
|
| 403 |
+
throw new Error(`path does not exist: ${root}`);
|
| 404 |
+
}
|
| 405 |
+
return walkMarkdownFiles(root);
|
| 406 |
+
}),
|
| 407 |
+
),
|
| 408 |
+
].toSorted((left, right) => left.localeCompare(right));
|
| 409 |
+
|
| 410 |
+
const errors: DocsCheckError[] = [];
|
| 411 |
+
for (const docsJsonPath of findDocsJsonPaths(args.roots)) {
|
| 412 |
+
errors.push(...checkDocsJson(docsJsonPath));
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
for (const file of files) {
|
| 416 |
+
try {
|
| 417 |
+
const raw = fs.readFileSync(file);
|
| 418 |
+
const relative = cache ? path.relative(cache.root, file).split(path.sep).join("/") : "";
|
| 419 |
+
const cachePath =
|
| 420 |
+
relative && !relative.startsWith("../") && !path.isAbsolute(relative) ? relative : "";
|
| 421 |
+
const digest = cachePath ? createHash("sha256").update(raw).digest("hex") : "";
|
| 422 |
+
if (cachePath && cache?.previous.get(cachePath) === digest) {
|
| 423 |
+
cache.checked.set(cachePath, digest);
|
| 424 |
+
cacheHits += 1;
|
| 425 |
+
continue;
|
| 426 |
+
}
|
| 427 |
+
const pageErrors = await checkMdxFile(file, raw.toString("utf8"));
|
| 428 |
+
errors.push(...pageErrors);
|
| 429 |
+
if (cachePath && pageErrors.length === 0) {
|
| 430 |
+
cache?.checked.set(cachePath, digest);
|
| 431 |
+
}
|
| 432 |
+
} catch (error) {
|
| 433 |
+
errors.push(formatMdxError(file, error));
|
| 434 |
+
if (errors.length >= args.maxErrors) {
|
| 435 |
+
break;
|
| 436 |
+
}
|
| 437 |
+
}
|
| 438 |
+
}
|
| 439 |
+
|
| 440 |
+
const report = {
|
| 441 |
+
files: files.length,
|
| 442 |
+
errors: errors.map((error) => Object.assign({}, error, { file: relativize(cwd, error.file) })),
|
| 443 |
+
ms: Date.now() - startedAt,
|
| 444 |
+
...(cache ? { cacheHits } : {}),
|
| 445 |
+
};
|
| 446 |
+
|
| 447 |
+
if (args.jsonOut) {
|
| 448 |
+
fs.mkdirSync(path.dirname(path.resolve(args.jsonOut)), { recursive: true });
|
| 449 |
+
fs.writeFileSync(args.jsonOut, `${JSON.stringify(report, null, 2)}\n`);
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
if (report.errors.length === 0) {
|
| 453 |
+
cache?.save();
|
| 454 |
+
console.log(`Docs MDX check passed (${report.files} files, ${report.ms}ms).`);
|
| 455 |
+
if (cache) {
|
| 456 |
+
console.log(`Reused ${cacheHits} unchanged successful page check(s).`);
|
| 457 |
+
}
|
| 458 |
+
return;
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
console.error(`Docs MDX check failed (${report.errors.length} error(s), ${report.files} files).`);
|
| 462 |
+
for (const error of report.errors) {
|
| 463 |
+
const location =
|
| 464 |
+
error.line && error.column ? `${error.file}:${error.line}:${error.column}` : error.file;
|
| 465 |
+
console.error(`- ${location}: ${error.message}`);
|
| 466 |
+
}
|
| 467 |
+
process.exitCode = 1;
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
|
| 471 |
+
|
| 472 |
+
if (isMain) {
|
| 473 |
+
main().catch((error: unknown) => {
|
| 474 |
+
console.error(errorField(error, "stack") ?? error);
|
| 475 |
+
process.exit(1);
|
| 476 |
+
});
|
| 477 |
+
}
|
scripts/check-file-utils.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Check File Utils helper supports OpenClaw script workflows.
|
| 2 |
+
import { execFileSync } from "node:child_process";
|
| 3 |
+
import fs from "node:fs";
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
|
| 6 |
+
const DEFAULT_SKIPPED_DIR_NAMES = new Set(["node_modules", "dist", "coverage", ".generated"]);
|
| 7 |
+
export const REPO_SCAN_ROOTS = ["src", "test", "extensions", "packages", "ui", "scripts"] as const;
|
| 8 |
+
export const REPO_SCAN_SKIPPED_DIR_NAMES: ReadonlySet<string> = new Set([
|
| 9 |
+
".artifacts",
|
| 10 |
+
".generated",
|
| 11 |
+
"coverage",
|
| 12 |
+
"dist",
|
| 13 |
+
"fixtures",
|
| 14 |
+
"node_modules",
|
| 15 |
+
"vendor",
|
| 16 |
+
]);
|
| 17 |
+
// Bound the Git lookup before falling back to direct filesystem traversal.
|
| 18 |
+
const GIT_LS_FILES_TIMEOUT_MS = 30_000;
|
| 19 |
+
const GIT_LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
|
| 20 |
+
|
| 21 |
+
export function isCodeFile(filePath: string): boolean {
|
| 22 |
+
if (filePath.endsWith(".d.ts")) {
|
| 23 |
+
return false;
|
| 24 |
+
}
|
| 25 |
+
return /\.(?:[cm]?ts|[cm]?js|tsx|jsx)$/u.test(filePath);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
export function isTestRelatedFile(relativePath: string): boolean {
|
| 29 |
+
return (
|
| 30 |
+
/(?:^|[/.])(?:test|spec)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
| 31 |
+
/\.(?:e2e|live)\.test\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
| 32 |
+
/\.(?:test-helpers|test-utils|test-harness|test-support)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
| 33 |
+
/-(?:test-helpers|test-utils|test-harness|test-support)\.[cm]?[jt]sx?$/u.test(relativePath) ||
|
| 34 |
+
/(?:^|\/)(?:test|tests|test-helpers|test-utils|test-harness|test-support)\//u.test(
|
| 35 |
+
relativePath,
|
| 36 |
+
) ||
|
| 37 |
+
relativePath.startsWith("scripts/e2e/") ||
|
| 38 |
+
/^scripts\/.*-(?:client|e2e|harness|probe|smoke)\.[cm]?[jt]s$/u.test(relativePath)
|
| 39 |
+
);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function collectFilesSync(
|
| 43 |
+
rootDir: string,
|
| 44 |
+
options: {
|
| 45 |
+
includeFile: (filePath: string) => boolean;
|
| 46 |
+
skipDirNames?: ReadonlySet<string>;
|
| 47 |
+
},
|
| 48 |
+
): string[] {
|
| 49 |
+
const skipDirNames = options.skipDirNames ?? DEFAULT_SKIPPED_DIR_NAMES;
|
| 50 |
+
const files: string[] = [];
|
| 51 |
+
const stack = [rootDir];
|
| 52 |
+
|
| 53 |
+
while (stack.length > 0) {
|
| 54 |
+
const current = stack.pop();
|
| 55 |
+
if (!current) {
|
| 56 |
+
continue;
|
| 57 |
+
}
|
| 58 |
+
let entries: fs.Dirent[];
|
| 59 |
+
try {
|
| 60 |
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
| 61 |
+
} catch {
|
| 62 |
+
continue;
|
| 63 |
+
}
|
| 64 |
+
for (const entry of entries) {
|
| 65 |
+
const fullPath = path.join(current, entry.name);
|
| 66 |
+
if (entry.isDirectory()) {
|
| 67 |
+
if (skipDirNames.has(entry.name)) {
|
| 68 |
+
continue;
|
| 69 |
+
}
|
| 70 |
+
stack.push(fullPath);
|
| 71 |
+
continue;
|
| 72 |
+
}
|
| 73 |
+
if (entry.isFile() && options.includeFile(fullPath)) {
|
| 74 |
+
files.push(fullPath);
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
return files;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
export function listRepoFilesSync(
|
| 83 |
+
repoRoot: string,
|
| 84 |
+
options: {
|
| 85 |
+
includeFile: (relativePath: string) => boolean;
|
| 86 |
+
roots?: readonly string[];
|
| 87 |
+
skipDirNames?: ReadonlySet<string>;
|
| 88 |
+
},
|
| 89 |
+
): string[] {
|
| 90 |
+
const roots = options.roots ?? REPO_SCAN_ROOTS;
|
| 91 |
+
try {
|
| 92 |
+
return execFileSync("git", ["-C", repoRoot, "ls-files", "--", ...roots], {
|
| 93 |
+
encoding: "utf8",
|
| 94 |
+
stdio: ["ignore", "pipe", "ignore"],
|
| 95 |
+
timeout: GIT_LS_FILES_TIMEOUT_MS,
|
| 96 |
+
killSignal: "SIGKILL",
|
| 97 |
+
maxBuffer: GIT_LS_FILES_MAX_BUFFER,
|
| 98 |
+
})
|
| 99 |
+
.split(/\r?\n/u)
|
| 100 |
+
.filter(Boolean)
|
| 101 |
+
.map(toPosixPath)
|
| 102 |
+
.filter(options.includeFile)
|
| 103 |
+
.toSorted((left, right) => left.localeCompare(right));
|
| 104 |
+
} catch {
|
| 105 |
+
return roots
|
| 106 |
+
.flatMap((root) => {
|
| 107 |
+
const absoluteRoot = path.join(repoRoot, root);
|
| 108 |
+
if (!fs.existsSync(absoluteRoot)) {
|
| 109 |
+
return [];
|
| 110 |
+
}
|
| 111 |
+
return collectFilesSync(absoluteRoot, {
|
| 112 |
+
includeFile: (filePath) =>
|
| 113 |
+
options.includeFile(toPosixPath(path.relative(repoRoot, filePath))),
|
| 114 |
+
skipDirNames: options.skipDirNames ?? REPO_SCAN_SKIPPED_DIR_NAMES,
|
| 115 |
+
}).map((filePath) => toPosixPath(path.relative(repoRoot, filePath)));
|
| 116 |
+
})
|
| 117 |
+
.toSorted((left, right) => left.localeCompare(right));
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
export function toPosixPath(filePath: string): string {
|
| 122 |
+
if (path.sep === "/") {
|
| 123 |
+
return filePath;
|
| 124 |
+
}
|
| 125 |
+
return filePath.replaceAll("\\", "/");
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
export function relativeToCwd(filePath: string): string {
|
| 129 |
+
const relativePath = path.relative(process.cwd(), filePath) || filePath;
|
| 130 |
+
return toPosixPath(relativePath);
|
| 131 |
+
}
|
scripts/check-import-cycles.ts
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Check Import Cycles script supports OpenClaw repository automation.
|
| 3 |
+
import { readFileSync } from "node:fs";
|
| 4 |
+
import path from "node:path";
|
| 5 |
+
import { fileURLToPath } from "node:url";
|
| 6 |
+
import ts from "typescript";
|
| 7 |
+
import {
|
| 8 |
+
collectSourceFiles,
|
| 9 |
+
collectStronglyConnectedComponents,
|
| 10 |
+
formatCycle,
|
| 11 |
+
} from "./lib/import-cycle-graph.ts";
|
| 12 |
+
|
| 13 |
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
| 14 |
+
const scanRoots = ["src", "extensions", "scripts"] as const;
|
| 15 |
+
const sourceExtensions = [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"] as const;
|
| 16 |
+
const testSourcePattern = /(?:\.test|\.e2e\.test)\.[cm]?[tj]sx?$/;
|
| 17 |
+
const generatedSourcePattern = /\.(?:generated|bundle)\.[tj]s$/;
|
| 18 |
+
const declarationSourcePattern = /\.d\.[cm]?ts$/;
|
| 19 |
+
const ignoredPathPartPattern =
|
| 20 |
+
/(^|\/)(node_modules|dist|build|coverage|\.artifacts|\.git|assets)(\/|$)/;
|
| 21 |
+
|
| 22 |
+
function shouldSkipRepoPath(repoPath: string): boolean {
|
| 23 |
+
return (
|
| 24 |
+
ignoredPathPartPattern.test(repoPath) ||
|
| 25 |
+
testSourcePattern.test(repoPath) ||
|
| 26 |
+
generatedSourcePattern.test(repoPath) ||
|
| 27 |
+
declarationSourcePattern.test(repoPath)
|
| 28 |
+
);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
function createSourceResolver(files: readonly string[]) {
|
| 32 |
+
const fileSet = new Set(files);
|
| 33 |
+
const pathMap = new Map<string, string>();
|
| 34 |
+
for (const file of files) {
|
| 35 |
+
const parsed = path.posix.parse(file);
|
| 36 |
+
const extensionless = path.posix.join(parsed.dir, parsed.name);
|
| 37 |
+
pathMap.set(extensionless, file);
|
| 38 |
+
if (file.endsWith(".ts")) {
|
| 39 |
+
pathMap.set(`${extensionless}.js`, file);
|
| 40 |
+
} else if (file.endsWith(".tsx")) {
|
| 41 |
+
pathMap.set(`${extensionless}.jsx`, file);
|
| 42 |
+
} else if (file.endsWith(".mts")) {
|
| 43 |
+
pathMap.set(`${extensionless}.mjs`, file);
|
| 44 |
+
} else if (file.endsWith(".cts")) {
|
| 45 |
+
pathMap.set(`${extensionless}.cjs`, file);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
return (importer: string, specifier: string): string | null => {
|
| 49 |
+
if (!specifier.startsWith(".")) {
|
| 50 |
+
return null;
|
| 51 |
+
}
|
| 52 |
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(importer), specifier));
|
| 53 |
+
if (fileSet.has(base)) {
|
| 54 |
+
return base;
|
| 55 |
+
}
|
| 56 |
+
const mappedBase = pathMap.get(base);
|
| 57 |
+
if (mappedBase) {
|
| 58 |
+
return mappedBase;
|
| 59 |
+
}
|
| 60 |
+
const candidates = [
|
| 61 |
+
...sourceExtensions.map((extension) => `${base}${extension}`),
|
| 62 |
+
`${base}/index.ts`,
|
| 63 |
+
`${base}/index.tsx`,
|
| 64 |
+
`${base}/index.js`,
|
| 65 |
+
`${base}/index.mjs`,
|
| 66 |
+
];
|
| 67 |
+
for (const candidate of candidates) {
|
| 68 |
+
if (fileSet.has(candidate)) {
|
| 69 |
+
return candidate;
|
| 70 |
+
}
|
| 71 |
+
const mapped = pathMap.get(candidate);
|
| 72 |
+
if (mapped) {
|
| 73 |
+
return mapped;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
return null;
|
| 77 |
+
};
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
function importDeclarationHasRuntimeEdge(node: ts.ImportDeclaration): boolean {
|
| 81 |
+
if (!node.importClause) {
|
| 82 |
+
return true;
|
| 83 |
+
}
|
| 84 |
+
if (node.importClause.isTypeOnly) {
|
| 85 |
+
return false;
|
| 86 |
+
}
|
| 87 |
+
const bindings = node.importClause.namedBindings;
|
| 88 |
+
if (node.importClause.name || !bindings || ts.isNamespaceImport(bindings)) {
|
| 89 |
+
return true;
|
| 90 |
+
}
|
| 91 |
+
return bindings.elements.some((element) => !element.isTypeOnly);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
function exportDeclarationHasRuntimeEdge(node: ts.ExportDeclaration): boolean {
|
| 95 |
+
if (!node.moduleSpecifier || node.isTypeOnly) {
|
| 96 |
+
return false;
|
| 97 |
+
}
|
| 98 |
+
const clause = node.exportClause;
|
| 99 |
+
if (!clause || ts.isNamespaceExport(clause)) {
|
| 100 |
+
return true;
|
| 101 |
+
}
|
| 102 |
+
return clause.elements.some((element) => !element.isTypeOnly);
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
function collectRuntimeStaticImports(
|
| 106 |
+
file: string,
|
| 107 |
+
resolveSource: ReturnType<typeof createSourceResolver>,
|
| 108 |
+
) {
|
| 109 |
+
const sourceFile = ts.createSourceFile(
|
| 110 |
+
file,
|
| 111 |
+
readFileSync(path.join(repoRoot, file), "utf8"),
|
| 112 |
+
ts.ScriptTarget.Latest,
|
| 113 |
+
false,
|
| 114 |
+
);
|
| 115 |
+
const imports: string[] = [];
|
| 116 |
+
const visit = (node: ts.Node) => {
|
| 117 |
+
let specifier: string | undefined;
|
| 118 |
+
let include = false;
|
| 119 |
+
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
| 120 |
+
specifier = node.moduleSpecifier.text;
|
| 121 |
+
include = importDeclarationHasRuntimeEdge(node);
|
| 122 |
+
} else if (
|
| 123 |
+
ts.isExportDeclaration(node) &&
|
| 124 |
+
node.moduleSpecifier &&
|
| 125 |
+
ts.isStringLiteral(node.moduleSpecifier)
|
| 126 |
+
) {
|
| 127 |
+
specifier = node.moduleSpecifier.text;
|
| 128 |
+
include = exportDeclarationHasRuntimeEdge(node);
|
| 129 |
+
}
|
| 130 |
+
if (include && specifier) {
|
| 131 |
+
const resolved = resolveSource(file, specifier);
|
| 132 |
+
if (resolved) {
|
| 133 |
+
imports.push(resolved);
|
| 134 |
+
}
|
| 135 |
+
}
|
| 136 |
+
ts.forEachChild(node, visit);
|
| 137 |
+
};
|
| 138 |
+
visit(sourceFile);
|
| 139 |
+
return imports.toSorted((left, right) => left.localeCompare(right));
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
function main(): number {
|
| 143 |
+
const files = scanRoots.flatMap((root) =>
|
| 144 |
+
collectSourceFiles(path.join(repoRoot, root), {
|
| 145 |
+
repoRoot,
|
| 146 |
+
sourceExtensions,
|
| 147 |
+
shouldSkipRepoPath,
|
| 148 |
+
}),
|
| 149 |
+
);
|
| 150 |
+
const resolveSource = createSourceResolver(files);
|
| 151 |
+
const graph = new Map(
|
| 152 |
+
files.map((file): [string, string[]] => [
|
| 153 |
+
file,
|
| 154 |
+
collectRuntimeStaticImports(file, resolveSource),
|
| 155 |
+
]),
|
| 156 |
+
);
|
| 157 |
+
const components = collectStronglyConnectedComponents(graph);
|
| 158 |
+
|
| 159 |
+
console.log(`Import cycle check: ${components.length} runtime value cycle(s).`);
|
| 160 |
+
if (components.length === 0) {
|
| 161 |
+
return 0;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
console.error("\nRuntime value import cycles:");
|
| 165 |
+
for (const component of components) {
|
| 166 |
+
console.error(`\n# component size ${component.length}`);
|
| 167 |
+
console.error(formatCycle(component, graph));
|
| 168 |
+
}
|
| 169 |
+
console.error("\nBreak the cycle or convert type-only edges to `import type`.");
|
| 170 |
+
return 1;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
process.exitCode = main();
|
scripts/check-kysely-guardrails.mts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Enforces Kysely and SQLite guardrails in infrastructure code.
|
| 4 |
+
import { promises as fs } from "node:fs";
|
| 5 |
+
import { createRequire } from "node:module";
|
| 6 |
+
import path from "node:path";
|
| 7 |
+
import type { Expression, ImportDeclaration, Node, SourceFile } from "typescript";
|
| 8 |
+
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
| 9 |
+
import {
|
| 10 |
+
collectTypeScriptFilesFromRoots,
|
| 11 |
+
getPropertyNameText,
|
| 12 |
+
runAsScript,
|
| 13 |
+
toLine,
|
| 14 |
+
unwrapExpression,
|
| 15 |
+
} from "./lib/ts-guard-utils.mts";
|
| 16 |
+
|
| 17 |
+
const require = createRequire(import.meta.url);
|
| 18 |
+
const ts: typeof import("typescript") = require("typescript");
|
| 19 |
+
|
| 20 |
+
const repoRoot = resolveRepoRoot(import.meta.url);
|
| 21 |
+
const sourceRoots = [path.join(repoRoot, "src")];
|
| 22 |
+
const nodeSqliteBoundaryRoots = [
|
| 23 |
+
path.join(repoRoot, "src"),
|
| 24 |
+
path.join(repoRoot, "extensions"),
|
| 25 |
+
path.join(repoRoot, "packages"),
|
| 26 |
+
];
|
| 27 |
+
|
| 28 |
+
const nodeSqliteConstructorOwnerPaths = new Set([
|
| 29 |
+
"src/infra/node-sqlite.ts",
|
| 30 |
+
"src/infra/sqlite-runtime-version.ts",
|
| 31 |
+
]);
|
| 32 |
+
|
| 33 |
+
const kyselyRawAllowPaths = new Set(["src/infra/kysely-sync.ts"]);
|
| 34 |
+
|
| 35 |
+
const compiledRawAllowPaths = new Set(["src/infra/kysely-node-sqlite.ts"]);
|
| 36 |
+
|
| 37 |
+
const rawSqliteAllowPathGroups = {
|
| 38 |
+
"native Kysely adapter and sync execution": [
|
| 39 |
+
"src/infra/kysely-node-sqlite.ts",
|
| 40 |
+
"src/infra/kysely-sync-cache-state.ts",
|
| 41 |
+
"src/infra/kysely-sync.ts",
|
| 42 |
+
],
|
| 43 |
+
"SQLite database lifecycle, schema, transactions, and pragmas": [
|
| 44 |
+
"src/infra/node-sqlite.ts",
|
| 45 |
+
"src/infra/sqlite-index-schema.ts",
|
| 46 |
+
"src/infra/sqlite-integrity.ts",
|
| 47 |
+
"src/infra/sqlite-pragma.test-support.ts",
|
| 48 |
+
"src/infra/sqlite-schema-contract.ts",
|
| 49 |
+
"src/infra/sqlite-strict.ts",
|
| 50 |
+
"src/infra/sqlite-transaction.ts",
|
| 51 |
+
"src/infra/sqlite-user-version.ts",
|
| 52 |
+
"src/infra/sqlite-wal.ts",
|
| 53 |
+
"src/state/openclaw-agent-db-maintenance.ts",
|
| 54 |
+
"src/state/openclaw-agent-db-registry.ts",
|
| 55 |
+
"src/state/openclaw-agent-db-registry-listing.ts",
|
| 56 |
+
"src/state/openclaw-agent-db-schema-helpers.ts",
|
| 57 |
+
// Existing schema ownership/version preflight, split from schema-helpers for readers.
|
| 58 |
+
"src/state/openclaw-agent-db-schema-read.ts",
|
| 59 |
+
"src/state/openclaw-agent-db-metadata.ts",
|
| 60 |
+
"src/state/openclaw-agent-db-schema.ts",
|
| 61 |
+
"src/state/openclaw-agent-db-session-nodes-migration.ts",
|
| 62 |
+
"src/state/openclaw-agent-db-session-migrations.ts",
|
| 63 |
+
"src/state/openclaw-agent-db-session-provenance.ts",
|
| 64 |
+
"src/state/openclaw-agent-db.ts",
|
| 65 |
+
"src/state/openclaw-state-db-audit-migration.ts",
|
| 66 |
+
"src/state/openclaw-state-db-delivery-queue-backfill.ts",
|
| 67 |
+
"src/state/openclaw-state-db-legacy-backfills.ts",
|
| 68 |
+
"src/state/openclaw-state-db-maintenance.ts",
|
| 69 |
+
"src/state/openclaw-state-db-operator-approval-migration.ts",
|
| 70 |
+
"src/state/openclaw-state-db-schema-additive.ts",
|
| 71 |
+
"src/state/openclaw-state-db-schema-helpers.ts",
|
| 72 |
+
"src/state/openclaw-state-db-schema-repair.ts",
|
| 73 |
+
"src/state/openclaw-state-db-schema-v12-foldin.ts",
|
| 74 |
+
"src/state/openclaw-state-db-schema-v13-widerow.ts",
|
| 75 |
+
"src/state/openclaw-state-db-startup-checkpoint.ts",
|
| 76 |
+
"src/state/openclaw-state-db-table-retirements.ts",
|
| 77 |
+
"src/state/openclaw-state-db-fast-path.ts",
|
| 78 |
+
"src/state/openclaw-state-db.ts",
|
| 79 |
+
"src/state/openclaw-state-ownership-operations.ts",
|
| 80 |
+
"src/transcripts/sqlite-schema.ts",
|
| 81 |
+
"src/state/sqlite-schema-shape.test-support.ts",
|
| 82 |
+
],
|
| 83 |
+
"cross-process SQLite coordination locks": ["src/infra/sqlite-coordinator.ts"],
|
| 84 |
+
"backup snapshot maintenance": [
|
| 85 |
+
"src/commands/backup-verify.ts",
|
| 86 |
+
"src/infra/backup-create.ts",
|
| 87 |
+
"src/snapshot/git-backup-codec.ts",
|
| 88 |
+
"src/snapshot/local-repository.ts",
|
| 89 |
+
],
|
| 90 |
+
"agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"],
|
| 91 |
+
"read-only shared state database access": [
|
| 92 |
+
"src/claws/package-resume.ts",
|
| 93 |
+
"src/state/openclaw-agent-db-readonly.ts",
|
| 94 |
+
"src/state/openclaw-state-db-readonly.ts",
|
| 95 |
+
],
|
| 96 |
+
"cold-process read-only relay lookup avoids the shared state writer lifecycle": [
|
| 97 |
+
"src/agents/harness/native-hook-relay-client.worker.ts",
|
| 98 |
+
],
|
| 99 |
+
"read-only schema preflight and integrity verification access": [
|
| 100 |
+
"src/state/openclaw-database-preflight.ts",
|
| 101 |
+
"src/state/openclaw-database-verify.worker.ts",
|
| 102 |
+
"src/state/openclaw-state-ownership.ts",
|
| 103 |
+
],
|
| 104 |
+
"quarantine store must work when other databases are damaged": [
|
| 105 |
+
"src/state/openclaw-quarantine-store.ts",
|
| 106 |
+
],
|
| 107 |
+
"read-only SQLite status probes": [
|
| 108 |
+
"src/commands/doctor-db-bloat.ts",
|
| 109 |
+
"src/commands/status.scan.shared.ts",
|
| 110 |
+
],
|
| 111 |
+
"doctor SQLite maintenance and legacy state migration": [
|
| 112 |
+
"src/commands/doctor-agent-memory-schema.ts",
|
| 113 |
+
"src/commands/doctor/cron/legacy-run-log-migration.ts",
|
| 114 |
+
"src/commands/doctor/cron/migration-ledger.ts",
|
| 115 |
+
"src/commands/doctor-sqlite-compact.ts",
|
| 116 |
+
"src/commands/doctor-session-sqlite.ts",
|
| 117 |
+
"src/commands/doctor-session-sqlite-readers.ts",
|
| 118 |
+
"src/commands/doctor-session-sqlite-transcript-readers.ts",
|
| 119 |
+
"src/commands/doctor-session-sqlite-recover-report.ts",
|
| 120 |
+
"src/commands/doctor-state-sqlite-compact.ts",
|
| 121 |
+
// Disposable import planning only; canonical session tables still use Kysely.
|
| 122 |
+
"src/config/sessions/session-accessor.sqlite-import-stage.ts",
|
| 123 |
+
"src/infra/state-migrations.task-sidecar-rows.ts",
|
| 124 |
+
"src/infra/state-migrations.storage.ts",
|
| 125 |
+
"src/infra/state-migrations.cron-run-logs.ts",
|
| 126 |
+
"src/infra/state-migrations.debug-proxy.ts",
|
| 127 |
+
"src/infra/state-migrations.meeting-transcripts-detection.ts",
|
| 128 |
+
"src/infra/state-migrations.meeting-transcripts-files.ts",
|
| 129 |
+
"src/infra/state-migrations.meeting-transcripts-verify.ts",
|
| 130 |
+
"src/infra/state-migrations.media-persistence.ts",
|
| 131 |
+
"src/infra/state-migrations.transcript-directives-archives.ts",
|
| 132 |
+
"src/infra/state-migrations.transcript-directives.ts",
|
| 133 |
+
// Doctor integrity PRAGMAs and lossless native 64-bit orphan-row preservation.
|
| 134 |
+
"src/state/openclaw-state-db-task-delivery-recovery.ts",
|
| 135 |
+
],
|
| 136 |
+
"session entry cache connection-local validity counters": [
|
| 137 |
+
"src/config/sessions/session-accessor.sqlite-entry-cache.ts",
|
| 138 |
+
],
|
| 139 |
+
"device pairing cache connection-local validity counters": ["src/infra/device-pairing-store.ts"],
|
| 140 |
+
"Kysely-backed stores that own a DatabaseSync boundary": [
|
| 141 |
+
"src/acp/event-ledger.ts",
|
| 142 |
+
"src/state/user-profiles.ts",
|
| 143 |
+
"src/cron/store.ts",
|
| 144 |
+
"src/infra/outbound/current-conversation-bindings.ts",
|
| 145 |
+
"src/media/store.ts",
|
| 146 |
+
"src/plugin-sdk/memory-core-host-engine-storage.ts",
|
| 147 |
+
"src/plugins/installed-plugin-index-record-reader.ts",
|
| 148 |
+
"src/plugins/installed-plugin-index-store-write.ts",
|
| 149 |
+
"src/plugin-state/plugin-state-store.sqlite.ts",
|
| 150 |
+
"src/proxy-capture/store.sqlite.ts",
|
| 151 |
+
"src/tasks/task-flow-registry.store.sqlite.ts",
|
| 152 |
+
"src/tasks/task-registry.store.kernel.ts",
|
| 153 |
+
],
|
| 154 |
+
};
|
| 155 |
+
|
| 156 |
+
type GuardViolation = { line: number; message: string };
|
| 157 |
+
|
| 158 |
+
const rawSqliteAllowPathReasons = new Map<string, string>();
|
| 159 |
+
for (const [reason, paths] of Object.entries(rawSqliteAllowPathGroups)) {
|
| 160 |
+
for (const allowedPath of paths) {
|
| 161 |
+
if (rawSqliteAllowPathReasons.has(allowedPath)) {
|
| 162 |
+
throw new Error(`Duplicate raw SQLite allowlist path: ${allowedPath}`);
|
| 163 |
+
}
|
| 164 |
+
rawSqliteAllowPathReasons.set(allowedPath, reason);
|
| 165 |
+
}
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
function lineText(sourceFile: SourceFile, node: Node) {
|
| 169 |
+
const line = toLine(sourceFile, node);
|
| 170 |
+
return sourceFile.text.split("\n")[line - 1] ?? "";
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function hasAllowComment(sourceFile: SourceFile, node: Node, token: string) {
|
| 174 |
+
const line = lineText(sourceFile, node);
|
| 175 |
+
if (line.includes(token)) {
|
| 176 |
+
return true;
|
| 177 |
+
}
|
| 178 |
+
const leading = ts.getLeadingCommentRanges(sourceFile.text, node.pos) ?? [];
|
| 179 |
+
return leading.some((range) => sourceFile.text.slice(range.pos, range.end).includes(token));
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function importSource(node: ImportDeclaration) {
|
| 183 |
+
const moduleSpecifier = node.moduleSpecifier;
|
| 184 |
+
return ts.isStringLiteral(moduleSpecifier) ? moduleSpecifier.text : "";
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function collectImports(sourceFile: SourceFile) {
|
| 188 |
+
const kyselySqlNames = new Set<string>();
|
| 189 |
+
const compiledQueryNames = new Set<string>();
|
| 190 |
+
const syncHelperNames = new Set<string>();
|
| 191 |
+
let hasKyselyContext = false;
|
| 192 |
+
let hasSqliteContext = false;
|
| 193 |
+
|
| 194 |
+
for (const statement of sourceFile.statements) {
|
| 195 |
+
if (!ts.isImportDeclaration(statement)) {
|
| 196 |
+
continue;
|
| 197 |
+
}
|
| 198 |
+
const source = importSource(statement);
|
| 199 |
+
const clause = statement.importClause;
|
| 200 |
+
const namedBindings = clause?.namedBindings;
|
| 201 |
+
|
| 202 |
+
if (source === "kysely") {
|
| 203 |
+
hasKyselyContext = true;
|
| 204 |
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
| 205 |
+
for (const element of namedBindings.elements) {
|
| 206 |
+
const importedName = element.propertyName?.text ?? element.name.text;
|
| 207 |
+
if (importedName === "sql") {
|
| 208 |
+
kyselySqlNames.add(element.name.text);
|
| 209 |
+
}
|
| 210 |
+
if (importedName === "CompiledQuery") {
|
| 211 |
+
compiledQueryNames.add(element.name.text);
|
| 212 |
+
}
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
if (source.endsWith("kysely-sync.js") || source.endsWith("kysely-node-sqlite.js")) {
|
| 218 |
+
hasKyselyContext = true;
|
| 219 |
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
| 220 |
+
for (const element of namedBindings.elements) {
|
| 221 |
+
const importedName = element.propertyName?.text ?? element.name.text;
|
| 222 |
+
if (
|
| 223 |
+
importedName === "executeSqliteQuerySync" ||
|
| 224 |
+
importedName === "executeSqliteQueryTakeFirstSync"
|
| 225 |
+
) {
|
| 226 |
+
syncHelperNames.add(element.name.text);
|
| 227 |
+
}
|
| 228 |
+
if (importedName === "getNodeSqliteKysely") {
|
| 229 |
+
hasKyselyContext = true;
|
| 230 |
+
hasSqliteContext = true;
|
| 231 |
+
}
|
| 232 |
+
}
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
if (
|
| 237 |
+
source === "node:sqlite" ||
|
| 238 |
+
source.endsWith("node-sqlite.js") ||
|
| 239 |
+
source.endsWith("sqlite-transaction.js") ||
|
| 240 |
+
source.endsWith("sqlite-wal.js") ||
|
| 241 |
+
source.endsWith("openclaw-state-db.js")
|
| 242 |
+
) {
|
| 243 |
+
hasSqliteContext = true;
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
return {
|
| 248 |
+
compiledQueryNames,
|
| 249 |
+
hasKyselyContext,
|
| 250 |
+
hasSqliteContext,
|
| 251 |
+
kyselySqlNames,
|
| 252 |
+
syncHelperNames,
|
| 253 |
+
};
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function addViolation(
|
| 257 |
+
violations: GuardViolation[],
|
| 258 |
+
sourceFile: SourceFile,
|
| 259 |
+
node: Node,
|
| 260 |
+
message: string,
|
| 261 |
+
) {
|
| 262 |
+
violations.push({
|
| 263 |
+
line: toLine(sourceFile, node),
|
| 264 |
+
message,
|
| 265 |
+
});
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
function isIdentifierNamed(node: Expression, names: Set<string>) {
|
| 269 |
+
const unwrapped = unwrapExpression(node);
|
| 270 |
+
return ts.isIdentifier(unwrapped) && names.has(unwrapped.text);
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
function isTestPath(relativePath: string) {
|
| 274 |
+
return (
|
| 275 |
+
/\.(?:test|spec|e2e)\.ts$/u.test(relativePath) ||
|
| 276 |
+
relativePath.includes(".test-helpers.") ||
|
| 277 |
+
relativePath.includes(".test-support.")
|
| 278 |
+
);
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function isSqliteStorePath(relativePath: string) {
|
| 282 |
+
return relativePath.endsWith(".sqlite.ts") || relativePath.includes(".store.sqlite.ts");
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
function collectNodeSqliteBoundaryViolations(content: string, relativePath: string) {
|
| 286 |
+
if (isTestPath(relativePath) || nodeSqliteConstructorOwnerPaths.has(relativePath)) {
|
| 287 |
+
return [];
|
| 288 |
+
}
|
| 289 |
+
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true);
|
| 290 |
+
const constructorNames = new Set<string>();
|
| 291 |
+
|
| 292 |
+
function collectConstructorNames(node: Node) {
|
| 293 |
+
if (ts.isImportDeclaration(node) && importSource(node) === "node:sqlite") {
|
| 294 |
+
const namedBindings = node.importClause?.namedBindings;
|
| 295 |
+
if (namedBindings && ts.isNamedImports(namedBindings)) {
|
| 296 |
+
for (const element of namedBindings.elements) {
|
| 297 |
+
if ((element.propertyName?.text ?? element.name.text) === "DatabaseSync") {
|
| 298 |
+
constructorNames.add(element.name.text);
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
if (ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name)) {
|
| 304 |
+
for (const element of node.name.elements) {
|
| 305 |
+
if (
|
| 306 |
+
!element.dotDotDotToken &&
|
| 307 |
+
ts.isIdentifier(element.name) &&
|
| 308 |
+
(element.propertyName ? getPropertyNameText(element.propertyName) : element.name.text) ===
|
| 309 |
+
"DatabaseSync"
|
| 310 |
+
) {
|
| 311 |
+
constructorNames.add(element.name.text);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
ts.forEachChild(node, collectConstructorNames);
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
collectConstructorNames(sourceFile);
|
| 319 |
+
const violations: GuardViolation[] = [];
|
| 320 |
+
function visit(node: Node) {
|
| 321 |
+
if (ts.isNewExpression(node)) {
|
| 322 |
+
const expression = unwrapExpression(node.expression);
|
| 323 |
+
const isRawConstructor =
|
| 324 |
+
(ts.isIdentifier(expression) && constructorNames.has(expression.text)) ||
|
| 325 |
+
(ts.isPropertyAccessExpression(expression) &&
|
| 326 |
+
getPropertyNameText(expression.name) === "DatabaseSync");
|
| 327 |
+
if (isRawConstructor) {
|
| 328 |
+
addViolation(
|
| 329 |
+
violations,
|
| 330 |
+
sourceFile,
|
| 331 |
+
node,
|
| 332 |
+
"production node:sqlite connections must use openNodeSqliteDatabase",
|
| 333 |
+
);
|
| 334 |
+
}
|
| 335 |
+
}
|
| 336 |
+
ts.forEachChild(node, visit);
|
| 337 |
+
}
|
| 338 |
+
visit(sourceFile);
|
| 339 |
+
return violations;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
function isLikelySqliteReceiver(expression: Expression) {
|
| 343 |
+
const unwrapped = unwrapExpression(expression);
|
| 344 |
+
if (ts.isIdentifier(unwrapped)) {
|
| 345 |
+
return /^(?:db|database|legacyDb|stateDb|agentDb)$/u.test(unwrapped.text);
|
| 346 |
+
}
|
| 347 |
+
return ts.isPropertyAccessExpression(unwrapped) && getPropertyNameText(unwrapped.name) === "db";
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
function isPersistedRowExpression(expression: Expression) {
|
| 351 |
+
const unwrapped = unwrapExpression(expression);
|
| 352 |
+
if (ts.isPropertyAccessExpression(unwrapped)) {
|
| 353 |
+
const owner = unwrapExpression(unwrapped.expression);
|
| 354 |
+
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
|
| 355 |
+
}
|
| 356 |
+
if (ts.isElementAccessExpression(unwrapped)) {
|
| 357 |
+
const owner = unwrapExpression(unwrapped.expression);
|
| 358 |
+
return ts.isIdentifier(owner) && /^(?:row|record|entry)$/u.test(owner.text);
|
| 359 |
+
}
|
| 360 |
+
return false;
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function isPersistedStringCastType(typeText: string) {
|
| 364 |
+
return [
|
| 365 |
+
/\bTaskRecord\["(?:runtime|scopeKind|status|deliveryStatus|notifyPolicy|terminalOutcome)"\]/u,
|
| 366 |
+
/\bTaskFlowRecord\["(?:status|notifyPolicy)"\]/u,
|
| 367 |
+
/\bTaskFlowSyncMode\b/u,
|
| 368 |
+
/\bVirtualAgentFsEntryKind\b/u,
|
| 369 |
+
/\b[A-Z][A-Za-z0-9]*(?:Status|Kind|Mode|Policy|Runtime|Outcome)\b/u,
|
| 370 |
+
].some((pattern) => pattern.test(typeText));
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
/**
|
| 374 |
+
* Collects Kysely/raw SQLite violations from one source file.
|
| 375 |
+
*/
|
| 376 |
+
function collectKyselyGuardrailViolations(content: string, relativePath: string) {
|
| 377 |
+
const sourceFile = ts.createSourceFile(relativePath, content, ts.ScriptTarget.Latest, true);
|
| 378 |
+
const imports = collectImports(sourceFile);
|
| 379 |
+
const violations: GuardViolation[] = [];
|
| 380 |
+
|
| 381 |
+
function visit(node: Node) {
|
| 382 |
+
if (
|
| 383 |
+
isSqliteStorePath(relativePath) &&
|
| 384 |
+
(ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) &&
|
| 385 |
+
isPersistedStringCastType(node.type.getText(sourceFile)) &&
|
| 386 |
+
isPersistedRowExpression(node.expression) &&
|
| 387 |
+
!hasAllowComment(sourceFile, node, "sqlite-allow-persisted-cast")
|
| 388 |
+
) {
|
| 389 |
+
addViolation(
|
| 390 |
+
violations,
|
| 391 |
+
sourceFile,
|
| 392 |
+
node,
|
| 393 |
+
"persisted SQLite enum-like values must be parsed through closed validators, not cast",
|
| 394 |
+
);
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
if (
|
| 398 |
+
ts.isCallExpression(node) &&
|
| 399 |
+
ts.isIdentifier(node.expression) &&
|
| 400 |
+
imports.syncHelperNames.has(node.expression.text) &&
|
| 401 |
+
node.typeArguments?.length &&
|
| 402 |
+
!hasAllowComment(sourceFile, node, "kysely-allow-raw")
|
| 403 |
+
) {
|
| 404 |
+
addViolation(
|
| 405 |
+
violations,
|
| 406 |
+
sourceFile,
|
| 407 |
+
node,
|
| 408 |
+
"sync helper row generic at call site; let Kysely infer builder result rows",
|
| 409 |
+
);
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
if (
|
| 413 |
+
ts.isTaggedTemplateExpression(node) &&
|
| 414 |
+
node.typeArguments?.length &&
|
| 415 |
+
isIdentifierNamed(node.tag, imports.kyselySqlNames) &&
|
| 416 |
+
!kyselyRawAllowPaths.has(relativePath) &&
|
| 417 |
+
!hasAllowComment(sourceFile, node, "kysely-allow-raw")
|
| 418 |
+
) {
|
| 419 |
+
addViolation(
|
| 420 |
+
violations,
|
| 421 |
+
sourceFile,
|
| 422 |
+
node,
|
| 423 |
+
"typed raw sql snippet needs a small helper or allowlisted boundary",
|
| 424 |
+
);
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
if (
|
| 428 |
+
ts.isCallExpression(node) &&
|
| 429 |
+
ts.isPropertyAccessExpression(node.expression) &&
|
| 430 |
+
isIdentifierNamed(node.expression.expression, imports.kyselySqlNames) &&
|
| 431 |
+
["ref", "table", "id", "raw"].includes(getPropertyNameText(node.expression.name) ?? "") &&
|
| 432 |
+
!hasAllowComment(sourceFile, node, "kysely-allow-raw")
|
| 433 |
+
) {
|
| 434 |
+
addViolation(
|
| 435 |
+
violations,
|
| 436 |
+
sourceFile,
|
| 437 |
+
node,
|
| 438 |
+
"raw Kysely identifier helper requires a closed-set validator and local allow comment",
|
| 439 |
+
);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
if (
|
| 443 |
+
imports.hasKyselyContext &&
|
| 444 |
+
ts.isPropertyAccessExpression(node) &&
|
| 445 |
+
getPropertyNameText(node.name) === "dynamic" &&
|
| 446 |
+
!hasAllowComment(sourceFile, node, "kysely-allow-raw")
|
| 447 |
+
) {
|
| 448 |
+
addViolation(
|
| 449 |
+
violations,
|
| 450 |
+
sourceFile,
|
| 451 |
+
node,
|
| 452 |
+
"Kysely dynamic refs bypass literal reference checking; use only behind closed unions",
|
| 453 |
+
);
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
if (
|
| 457 |
+
ts.isCallExpression(node) &&
|
| 458 |
+
ts.isPropertyAccessExpression(node.expression) &&
|
| 459 |
+
isIdentifierNamed(node.expression.expression, imports.compiledQueryNames) &&
|
| 460 |
+
getPropertyNameText(node.expression.name) === "raw" &&
|
| 461 |
+
!compiledRawAllowPaths.has(relativePath) &&
|
| 462 |
+
!hasAllowComment(sourceFile, node, "kysely-allow-raw")
|
| 463 |
+
) {
|
| 464 |
+
addViolation(
|
| 465 |
+
violations,
|
| 466 |
+
sourceFile,
|
| 467 |
+
node,
|
| 468 |
+
"CompiledQuery.raw is only allowed in the native SQLite dialect/test boundary",
|
| 469 |
+
);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
if (
|
| 473 |
+
imports.hasSqliteContext &&
|
| 474 |
+
!isTestPath(relativePath) &&
|
| 475 |
+
ts.isCallExpression(node) &&
|
| 476 |
+
ts.isPropertyAccessExpression(node.expression) &&
|
| 477 |
+
["prepare", "exec"].includes(getPropertyNameText(node.expression.name) ?? "") &&
|
| 478 |
+
isLikelySqliteReceiver(node.expression.expression) &&
|
| 479 |
+
!rawSqliteAllowPathReasons.has(relativePath) &&
|
| 480 |
+
!hasAllowComment(sourceFile, node, "sqlite-allow-raw")
|
| 481 |
+
) {
|
| 482 |
+
addViolation(
|
| 483 |
+
violations,
|
| 484 |
+
sourceFile,
|
| 485 |
+
node,
|
| 486 |
+
"new raw node:sqlite access requires Kysely or an explicit raw SQLite allowlist entry",
|
| 487 |
+
);
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
ts.forEachChild(node, visit);
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
visit(sourceFile);
|
| 494 |
+
return violations;
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
/**
|
| 498 |
+
* Collects Kysely guardrail violations across configured source roots.
|
| 499 |
+
*/
|
| 500 |
+
async function collectKyselyGuardrails() {
|
| 501 |
+
const files = await collectTypeScriptFilesFromRoots(sourceRoots, { includeTests: true });
|
| 502 |
+
const violations: Array<GuardViolation & { path: string }> = [];
|
| 503 |
+
for (const filePath of files) {
|
| 504 |
+
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
| 505 |
+
const content = await fs.readFile(filePath, "utf8");
|
| 506 |
+
for (const violation of collectKyselyGuardrailViolations(content, relativePath)) {
|
| 507 |
+
violations.push({ path: relativePath, ...violation });
|
| 508 |
+
}
|
| 509 |
+
}
|
| 510 |
+
const nodeSqliteFiles = await collectTypeScriptFilesFromRoots(nodeSqliteBoundaryRoots, {
|
| 511 |
+
includeTests: false,
|
| 512 |
+
});
|
| 513 |
+
for (const filePath of nodeSqliteFiles) {
|
| 514 |
+
const relativePath = path.relative(repoRoot, filePath).split(path.sep).join("/");
|
| 515 |
+
const content = await fs.readFile(filePath, "utf8");
|
| 516 |
+
for (const violation of collectNodeSqliteBoundaryViolations(content, relativePath)) {
|
| 517 |
+
violations.push({ path: relativePath, ...violation });
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
return violations;
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
/**
|
| 524 |
+
* Runs the Kysely guardrail check.
|
| 525 |
+
*/
|
| 526 |
+
async function main() {
|
| 527 |
+
const violations = await collectKyselyGuardrails();
|
| 528 |
+
if (violations.length === 0) {
|
| 529 |
+
console.log("Kysely guardrails OK");
|
| 530 |
+
return;
|
| 531 |
+
}
|
| 532 |
+
console.error("Kysely guardrail violations:");
|
| 533 |
+
for (const violation of violations) {
|
| 534 |
+
console.error(`- ${violation.path}:${violation.line}: ${violation.message}`);
|
| 535 |
+
}
|
| 536 |
+
process.exit(1);
|
| 537 |
+
}
|
| 538 |
+
|
| 539 |
+
runAsScript(import.meta.url, main);
|
scripts/check-live-cache.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Check Live Cache script supports OpenClaw repository automation.
|
| 2 |
+
|
| 3 |
+
import { LIVE_CACHE_TEST_ENABLED, logLiveCache } from "../src/agents/live-cache-test-support.js";
|
| 4 |
+
import { runLiveCacheRegression } from "../src/agents/test-helpers/live-cache-regression-runner.js";
|
| 5 |
+
|
| 6 |
+
if (!LIVE_CACHE_TEST_ENABLED) {
|
| 7 |
+
logLiveCache("skipped; set OPENCLAW_LIVE_TEST=1 and OPENCLAW_LIVE_CACHE_TEST=1");
|
| 8 |
+
process.exit(0);
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const result = await runLiveCacheRegression();
|
| 12 |
+
if (result.warnings.length > 0) {
|
| 13 |
+
process.stderr.write("\n[live-cache] non-blocking cache observations:\n");
|
| 14 |
+
for (const warning of result.warnings) {
|
| 15 |
+
process.stderr.write(`- ${warning}\n`);
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
if (result.regressions.length > 0) {
|
| 19 |
+
process.stderr.write("\n[live-cache] regressions detected:\n");
|
| 20 |
+
for (const regression of result.regressions) {
|
| 21 |
+
process.stderr.write(`- ${regression}\n`);
|
| 22 |
+
}
|
| 23 |
+
process.exit(1);
|
| 24 |
+
} else {
|
| 25 |
+
process.stderr.write("\n[live-cache] all regression floors satisfied\n");
|
| 26 |
+
process.exit(0);
|
| 27 |
+
}
|
scripts/check-media-download-helper-roundtrip.mts
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
// Checks extension media downloads keep helper-read/save calls close together.
|
| 3 |
+
import { execFileSync } from "node:child_process";
|
| 4 |
+
import { existsSync, readFileSync } from "node:fs";
|
| 5 |
+
|
| 6 |
+
const WINDOW_LINES = 80;
|
| 7 |
+
const READ_HELPER_RE = /\b(?:readRemoteMediaBuffer|fetchRemoteMedia)\s*\(/;
|
| 8 |
+
const SAVE_BUFFER_RE = /(?:\.|\b)saveMediaBuffer\s*\(/;
|
| 9 |
+
|
| 10 |
+
function listTrackedExtensionSources() {
|
| 11 |
+
return execFileSync("git", ["ls-files", "extensions/**/*.ts"], {
|
| 12 |
+
encoding: "utf8",
|
| 13 |
+
stdio: ["ignore", "pipe", "pipe"],
|
| 14 |
+
})
|
| 15 |
+
.split("\n")
|
| 16 |
+
.filter(Boolean)
|
| 17 |
+
.filter((file) => file.includes("/src/"))
|
| 18 |
+
.filter((file) => existsSync(file))
|
| 19 |
+
.filter((file) => !isTestOrFixture(file));
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function isTestOrFixture(file: string) {
|
| 23 |
+
return (
|
| 24 |
+
file.endsWith(".test.ts") ||
|
| 25 |
+
file.endsWith(".e2e.test.ts") ||
|
| 26 |
+
file.endsWith(".test-harness.ts") ||
|
| 27 |
+
file.endsWith(".test-utils.ts") ||
|
| 28 |
+
file.endsWith("/test-runtime.ts") ||
|
| 29 |
+
file.endsWith("/test-helpers.ts") ||
|
| 30 |
+
file.includes("/test-support/") ||
|
| 31 |
+
file.includes("/fixtures/")
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const findings: Array<{ file: string; line: number; saveLine: number }> = [];
|
| 36 |
+
|
| 37 |
+
for (const file of listTrackedExtensionSources()) {
|
| 38 |
+
const lines = readFileSync(file, "utf8").split("\n");
|
| 39 |
+
for (let index = 0; index < lines.length; index += 1) {
|
| 40 |
+
if (!READ_HELPER_RE.test(lines[index] ?? "")) {
|
| 41 |
+
continue;
|
| 42 |
+
}
|
| 43 |
+
const end = Math.min(lines.length, index + WINDOW_LINES);
|
| 44 |
+
for (let nextIndex = index; nextIndex < end; nextIndex += 1) {
|
| 45 |
+
if (!SAVE_BUFFER_RE.test(lines[nextIndex] ?? "")) {
|
| 46 |
+
continue;
|
| 47 |
+
}
|
| 48 |
+
findings.push({
|
| 49 |
+
file,
|
| 50 |
+
line: index + 1,
|
| 51 |
+
saveLine: nextIndex + 1,
|
| 52 |
+
});
|
| 53 |
+
break;
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
if (findings.length > 0) {
|
| 59 |
+
console.error("Avoid remote-media buffer/store round trips in plugin production code.");
|
| 60 |
+
console.error(
|
| 61 |
+
"Use saveRemoteMedia(...) for URL-to-store or saveResponseMedia(...) for fetched Response objects.",
|
| 62 |
+
);
|
| 63 |
+
for (const finding of findings) {
|
| 64 |
+
console.error(`- ${finding.file}:${finding.line} -> saveMediaBuffer at ${finding.saveLine}`);
|
| 65 |
+
}
|
| 66 |
+
process.exitCode = 1;
|
| 67 |
+
}
|
scripts/check-memory-fd-repro.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { runTsxCliShim } from "./lib/tsx-cli-shim.mjs";
|
| 2 |
+
|
| 3 |
+
await runTsxCliShim(import.meta.url, {
|
| 4 |
+
implementation: "./check-memory-fd-repro.mts",
|
| 5 |
+
});
|
scripts/check-native-state-schema-version.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
import fs from "node:fs";
|
| 4 |
+
import { isDirectRunUrl } from "./lib/direct-run.mjs";
|
| 5 |
+
|
| 6 |
+
const SWIFT_CONTRACT_PATH =
|
| 7 |
+
"apps/shared/OpenClawKit/Sources/OpenClawNativeState/OpenClawNativeStateSQLite.swift";
|
| 8 |
+
const TYPESCRIPT_CONTRACT_PATH = "src/state/openclaw-state-db-contract.ts";
|
| 9 |
+
|
| 10 |
+
function extractSingleVersion(source, pattern, label) {
|
| 11 |
+
const matches = [...source.matchAll(pattern)];
|
| 12 |
+
if (matches.length !== 1) {
|
| 13 |
+
throw new Error(`Expected exactly one ${label} declaration; found ${matches.length}`);
|
| 14 |
+
}
|
| 15 |
+
return Number(matches[0][1]);
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
export function compareNativeStateSchemaVersions({ swiftSource, typescriptSource }) {
|
| 19 |
+
const swiftVersion = extractSingleVersion(
|
| 20 |
+
swiftSource,
|
| 21 |
+
/^\s*private static let maximumSupportedSchemaVersion: Int64 = (\d+)\s*$/gmu,
|
| 22 |
+
"Swift maximumSupportedSchemaVersion",
|
| 23 |
+
);
|
| 24 |
+
const typescriptVersion = extractSingleVersion(
|
| 25 |
+
typescriptSource,
|
| 26 |
+
/^export const OPENCLAW_STATE_SCHEMA_VERSION = (\d+);\s*$/gmu,
|
| 27 |
+
"TypeScript OPENCLAW_STATE_SCHEMA_VERSION",
|
| 28 |
+
);
|
| 29 |
+
if (swiftVersion !== typescriptVersion) {
|
| 30 |
+
throw new Error(
|
| 31 |
+
`Native state schema version drift: Swift supports ${swiftVersion}, TypeScript owns ${typescriptVersion}`,
|
| 32 |
+
);
|
| 33 |
+
}
|
| 34 |
+
return swiftVersion;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export function checkNativeStateSchemaVersion(readFileSync = fs.readFileSync) {
|
| 38 |
+
return compareNativeStateSchemaVersions({
|
| 39 |
+
swiftSource: readFileSync(SWIFT_CONTRACT_PATH, "utf8"),
|
| 40 |
+
typescriptSource: readFileSync(TYPESCRIPT_CONTRACT_PATH, "utf8"),
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
if (isDirectRunUrl(process.argv[1], import.meta.url)) {
|
| 45 |
+
try {
|
| 46 |
+
const version = checkNativeStateSchemaVersion();
|
| 47 |
+
console.log(`native state schema version guard passed (v${version})`);
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 50 |
+
process.exitCode = 1;
|
| 51 |
+
}
|
| 52 |
+
}
|
scripts/check-no-conflict-markers.mjs
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Rejects unresolved merge conflict markers in tracked files.
|
| 4 |
+
import { spawnSync } from "node:child_process";
|
| 5 |
+
import path from "node:path";
|
| 6 |
+
import { fileURLToPath } from "node:url";
|
| 7 |
+
|
| 8 |
+
const CONFLICT_MARKER_GREP_PATTERN = "^(<<<<<<< |\\|\\|\\|\\|\\|\\|\\| |=======$|>>>>>>> )";
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Parses output from `git grep -n -z -o -I -E` into violation records.
|
| 12 |
+
* The record format is:
|
| 13 |
+
* path\0line-number\0match-text\n
|
| 14 |
+
* The path must be read from its NUL delimiter before any newline-based
|
| 15 |
+
* record splitting: -z reports paths verbatim, and git allows newlines in
|
| 16 |
+
* tracked filenames, so splitting the output on newlines first would cut a
|
| 17 |
+
* newline-containing path across two records and silently mangle it.
|
| 18 |
+
* Keeping parsing to exact grep match records avoids reading candidate files
|
| 19 |
+
* whole and keeps memory bounded regardless of file size.
|
| 20 |
+
*/
|
| 21 |
+
function parseGitGrepConflictMarkerOutput(stdout) {
|
| 22 |
+
const byPath = new Map();
|
| 23 |
+
const output = stdout.toString("utf8");
|
| 24 |
+
let cursor = 0;
|
| 25 |
+
|
| 26 |
+
while (cursor < output.length) {
|
| 27 |
+
const pathEnd = output.indexOf("\0", cursor);
|
| 28 |
+
if (pathEnd === -1) {
|
| 29 |
+
break;
|
| 30 |
+
}
|
| 31 |
+
const lineNumberEnd = output.indexOf("\0", pathEnd + 1);
|
| 32 |
+
if (lineNumberEnd === -1) {
|
| 33 |
+
break;
|
| 34 |
+
}
|
| 35 |
+
// With -o the match text is a single-line match, so the record ends at
|
| 36 |
+
// the next newline after the line-number field.
|
| 37 |
+
const matchEnd = output.indexOf("\n", lineNumberEnd + 1);
|
| 38 |
+
const relativePath = output.slice(cursor, pathEnd);
|
| 39 |
+
const lineNumber = Number(output.slice(pathEnd + 1, lineNumberEnd));
|
| 40 |
+
cursor = matchEnd === -1 ? output.length : matchEnd + 1;
|
| 41 |
+
if (!relativePath || !Number.isFinite(lineNumber) || lineNumber <= 0) {
|
| 42 |
+
continue;
|
| 43 |
+
}
|
| 44 |
+
const existing = byPath.get(relativePath);
|
| 45 |
+
if (existing) {
|
| 46 |
+
existing.push(lineNumber);
|
| 47 |
+
} else {
|
| 48 |
+
byPath.set(relativePath, [lineNumber]);
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const violations = [];
|
| 53 |
+
for (const [relativePath, lineNumbers] of byPath) {
|
| 54 |
+
lineNumbers.sort((a, b) => a - b);
|
| 55 |
+
violations.push({
|
| 56 |
+
filePath: relativePath,
|
| 57 |
+
lines: lineNumbers,
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
violations.sort((a, b) => a.filePath.localeCompare(b.filePath));
|
| 61 |
+
return violations;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Uses git grep to find exact conflict-marker matches in tracked files.
|
| 66 |
+
*/
|
| 67 |
+
export function findConflictMarkersInTrackedFiles(cwd = process.cwd()) {
|
| 68 |
+
const result = spawnSync(
|
| 69 |
+
"git",
|
| 70 |
+
["grep", "--no-color", "-n", "-z", "-o", "-I", "-E", CONFLICT_MARKER_GREP_PATTERN, "--", "."],
|
| 71 |
+
{
|
| 72 |
+
cwd,
|
| 73 |
+
encoding: "buffer",
|
| 74 |
+
},
|
| 75 |
+
);
|
| 76 |
+
if (result.status === 1) {
|
| 77 |
+
return [];
|
| 78 |
+
}
|
| 79 |
+
if (result.status !== 0) {
|
| 80 |
+
const stderr = result.stderr?.toString("utf8").trim();
|
| 81 |
+
throw new Error(stderr || `git grep failed with status ${result.status ?? "unknown"}`);
|
| 82 |
+
}
|
| 83 |
+
return parseGitGrepConflictMarkerOutput(result.stdout);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
/**
|
| 87 |
+
* Runs the merge conflict marker check.
|
| 88 |
+
*/
|
| 89 |
+
export async function main() {
|
| 90 |
+
const cwd = process.cwd();
|
| 91 |
+
const violations = findConflictMarkersInTrackedFiles(cwd);
|
| 92 |
+
if (violations.length === 0) {
|
| 93 |
+
return;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
console.error("Found unresolved merge conflict markers:");
|
| 97 |
+
for (const violation of violations) {
|
| 98 |
+
// findConflictMarkersInTrackedFiles already returns paths relative to cwd.
|
| 99 |
+
console.error(`- ${violation.filePath}:${violation.lines.join(",")}`);
|
| 100 |
+
}
|
| 101 |
+
process.exitCode = 1;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
| 105 |
+
main().catch(
|
| 106 |
+
/** @param {unknown} error */ (error) => {
|
| 107 |
+
console.error(error);
|
| 108 |
+
process.exit(1);
|
| 109 |
+
},
|
| 110 |
+
);
|
| 111 |
+
}
|
scripts/check-no-deprecated-channel-access.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Check No Deprecated Channel Access script supports OpenClaw repository automation.
|
| 2 |
+
import fs from "node:fs";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js";
|
| 5 |
+
import { classifyBundledExtensionSourcePath } from "./lib/extension-source-classifier.mts";
|
| 6 |
+
|
| 7 |
+
type Rule = {
|
| 8 |
+
label: string;
|
| 9 |
+
pattern: RegExp;
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
const RULES: Rule[] = [
|
| 13 |
+
{
|
| 14 |
+
label: "deprecated channel runtime",
|
| 15 |
+
pattern:
|
| 16 |
+
/\.channel\.(?:reply\.(?:createReplyDispatcherWithTyping|resolveHumanDelayConfig|dispatchReplyFromConfig|finalizeInboundContext|formatInboundEnvelope)|session\.(?:resolveStorePath|recordInboundSession)|inbound\.(?:runPreparedReply|dispatchReply)|media\.fetchRemoteMedia)\b/u,
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
label: "caller-owned prepared channel dispatch",
|
| 20 |
+
pattern: /\b(?:runDispatch|onPreDispatchFailure)\b/u,
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
label: "caller-owned reply dispatcher lifecycle",
|
| 24 |
+
pattern:
|
| 25 |
+
/\b(?:createReplyDispatcherWithTyping|dispatchInboundMessage(?:WithBufferedDispatcher|WithDispatcher)?|settleReplyDispatcher)\s*\(/u,
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
label: "deprecated channel ingress resolver aliases",
|
| 29 |
+
pattern:
|
| 30 |
+
/\b(?:resolved|result|directResolved|groupResolved)\.(?:legacyAccess|senderReasonCode|commandAuthorized|shouldBlockControlCommand)\b/u,
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
label: "inline deprecated channel ingress legacyAccess projection",
|
| 34 |
+
pattern: /\)\.legacyAccess\b/u,
|
| 35 |
+
},
|
| 36 |
+
{
|
| 37 |
+
label: "deprecated pairing-store access helper",
|
| 38 |
+
pattern: /\breadStoreAllowFromForDmPolicy\b/u,
|
| 39 |
+
},
|
| 40 |
+
{
|
| 41 |
+
label: "deprecated DM/group access helper",
|
| 42 |
+
pattern: /\bresolveDmGroupAccessWith(?:Lists|CommandGate)\b/u,
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
label: "deprecated DM/group access reason constants",
|
| 46 |
+
pattern: /\bDM_GROUP_ACCESS_REASON\b/u,
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
label: "deprecated group policy access helper",
|
| 50 |
+
pattern:
|
| 51 |
+
/\b(?:resolveSenderScopedGroupPolicy|evaluateSenderGroupAccess(?:ForPolicy)?|evaluateGroupRouteAccessForPolicy|evaluateMatchedGroupAccessForPolicy)\b/u,
|
| 52 |
+
},
|
| 53 |
+
{
|
| 54 |
+
label: "deprecated group access compatibility module",
|
| 55 |
+
pattern: /from\s+["']openclaw\/plugin-sdk\/group-access["']/u,
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
label: "deprecated command authorization helper",
|
| 59 |
+
pattern: /\bresolveSenderCommandAuthorization(?:WithRuntime)?\b/u,
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
label: "deprecated command auth SDK facade",
|
| 63 |
+
pattern: /from\s+["']openclaw\/plugin-sdk\/command-auth["']/u,
|
| 64 |
+
},
|
| 65 |
+
];
|
| 66 |
+
|
| 67 |
+
function collectBundledPluginProductionFiles(): string[] {
|
| 68 |
+
const extensionsDir = path.join(process.cwd(), "extensions");
|
| 69 |
+
return collectFilesSync(extensionsDir, {
|
| 70 |
+
includeFile(filePath) {
|
| 71 |
+
if (!isCodeFile(filePath)) {
|
| 72 |
+
return false;
|
| 73 |
+
}
|
| 74 |
+
const repoPath = relativeToCwd(filePath);
|
| 75 |
+
const classified = classifyBundledExtensionSourcePath(repoPath);
|
| 76 |
+
return classified.isProductionSource;
|
| 77 |
+
},
|
| 78 |
+
}).toSorted((left, right) => relativeToCwd(left).localeCompare(relativeToCwd(right)));
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function main() {
|
| 82 |
+
const offenders: Array<{ file: string; line: number; label: string; text: string }> = [];
|
| 83 |
+
for (const file of collectBundledPluginProductionFiles()) {
|
| 84 |
+
const content = fs.readFileSync(file, "utf8");
|
| 85 |
+
const lines = content.split(/\r?\n/u);
|
| 86 |
+
for (const [index, line] of lines.entries()) {
|
| 87 |
+
for (const rule of RULES) {
|
| 88 |
+
if (rule.pattern.test(line)) {
|
| 89 |
+
offenders.push({
|
| 90 |
+
file: relativeToCwd(file),
|
| 91 |
+
line: index + 1,
|
| 92 |
+
label: rule.label,
|
| 93 |
+
text: line.trim(),
|
| 94 |
+
});
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
if (offenders.length > 0) {
|
| 101 |
+
console.error(
|
| 102 |
+
"Bundled plugin production code must use modern channel runtime and access seams.",
|
| 103 |
+
);
|
| 104 |
+
for (const offender of offenders) {
|
| 105 |
+
console.error(`- ${offender.file}:${offender.line}: ${offender.label}: ${offender.text}`);
|
| 106 |
+
}
|
| 107 |
+
process.exit(1);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
console.log(
|
| 111 |
+
"OK: bundled plugin production code avoids deprecated channel runtime and access seams.",
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
main();
|
scripts/check-no-monolithic-plugin-sdk-entry-imports.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Check No Monolithic Plugin Sdk Entry Imports script supports OpenClaw repository automation.
|
| 2 |
+
import fs from "node:fs";
|
| 3 |
+
import path from "node:path";
|
| 4 |
+
import { discoverOpenClawPlugins } from "../src/plugins/discovery.js";
|
| 5 |
+
import { collectFilesSync, isCodeFile, relativeToCwd } from "./check-file-utils.js";
|
| 6 |
+
|
| 7 |
+
const LEGACY_BROAD_SUBPATH_PATTERNS = [
|
| 8 |
+
{
|
| 9 |
+
pattern: /["']openclaw\/plugin-sdk\/config-runtime["']/,
|
| 10 |
+
label: "openclaw/plugin-sdk/config-runtime",
|
| 11 |
+
},
|
| 12 |
+
{
|
| 13 |
+
pattern: /["']openclaw\/plugin-sdk\/infra-runtime["']/,
|
| 14 |
+
label: "openclaw/plugin-sdk/infra-runtime",
|
| 15 |
+
},
|
| 16 |
+
] as const;
|
| 17 |
+
|
| 18 |
+
function findLegacyBroadSubpathImports(content: string): string[] {
|
| 19 |
+
return LEGACY_BROAD_SUBPATH_PATTERNS.filter(({ pattern }) => pattern.test(content)).map(
|
| 20 |
+
({ label }) => label,
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
function collectPluginSourceFiles(rootDir: string): string[] {
|
| 25 |
+
const srcDir = path.join(rootDir, "src");
|
| 26 |
+
if (!fs.existsSync(srcDir)) {
|
| 27 |
+
return [];
|
| 28 |
+
}
|
| 29 |
+
return collectFilesSync(srcDir, {
|
| 30 |
+
includeFile: (filePath) => isCodeFile(filePath),
|
| 31 |
+
skipDirNames: new Set(["node_modules", "dist", ".git", "coverage"]),
|
| 32 |
+
});
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function collectSharedExtensionSourceFiles(): string[] {
|
| 36 |
+
return collectPluginSourceFiles(path.join(process.cwd(), "extensions", "shared"));
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
function collectBundledExtensionSourceFiles(): string[] {
|
| 40 |
+
const extensionsDir = path.join(process.cwd(), "extensions");
|
| 41 |
+
let entries: fs.Dirent[];
|
| 42 |
+
try {
|
| 43 |
+
entries = fs.readdirSync(extensionsDir, { withFileTypes: true });
|
| 44 |
+
} catch {
|
| 45 |
+
return [];
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
const files: string[] = [];
|
| 49 |
+
for (const entry of entries) {
|
| 50 |
+
if (!entry.isDirectory() || entry.name === "shared") {
|
| 51 |
+
continue;
|
| 52 |
+
}
|
| 53 |
+
for (const srcFile of collectPluginSourceFiles(path.join(extensionsDir, entry.name))) {
|
| 54 |
+
files.push(srcFile);
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
return files;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
function main() {
|
| 61 |
+
const discovery = discoverOpenClawPlugins({});
|
| 62 |
+
const bundledCandidates = discovery.candidates.filter((c) => c.origin === "bundled");
|
| 63 |
+
const filesToCheck = new Set<string>();
|
| 64 |
+
for (const candidate of bundledCandidates) {
|
| 65 |
+
filesToCheck.add(candidate.source);
|
| 66 |
+
for (const srcFile of collectPluginSourceFiles(candidate.rootDir)) {
|
| 67 |
+
filesToCheck.add(srcFile);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
for (const sharedFile of collectSharedExtensionSourceFiles()) {
|
| 71 |
+
filesToCheck.add(sharedFile);
|
| 72 |
+
}
|
| 73 |
+
for (const extensionFile of collectBundledExtensionSourceFiles()) {
|
| 74 |
+
filesToCheck.add(extensionFile);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const legacyBroadSubpathOffenders = new Map<string, string[]>();
|
| 78 |
+
for (const entryFile of filesToCheck) {
|
| 79 |
+
let content;
|
| 80 |
+
try {
|
| 81 |
+
content = fs.readFileSync(entryFile, "utf8");
|
| 82 |
+
} catch {
|
| 83 |
+
continue;
|
| 84 |
+
}
|
| 85 |
+
const legacyBroadSubpaths = findLegacyBroadSubpathImports(content);
|
| 86 |
+
if (legacyBroadSubpaths.length > 0) {
|
| 87 |
+
legacyBroadSubpathOffenders.set(entryFile, legacyBroadSubpaths);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
if (legacyBroadSubpathOffenders.size > 0) {
|
| 92 |
+
if (legacyBroadSubpathOffenders.size > 0) {
|
| 93 |
+
console.error(
|
| 94 |
+
"Bundled plugin source files must not import deprecated broad plugin-sdk subpaths.",
|
| 95 |
+
);
|
| 96 |
+
for (const [file, labels] of [...legacyBroadSubpathOffenders.entries()].toSorted(
|
| 97 |
+
([left], [right]) => left.localeCompare(right),
|
| 98 |
+
)) {
|
| 99 |
+
console.error(`- ${relativeToCwd(file)} (${labels.join(", ")})`);
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
console.error("Use focused openclaw/plugin-sdk/<domain> subpaths for bundled plugins.");
|
| 103 |
+
process.exit(1);
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
console.log(
|
| 107 |
+
`OK: bundled plugin source files avoid deprecated broad plugin-sdk subpaths (${filesToCheck.size} checked).`,
|
| 108 |
+
);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
main();
|
scripts/check-package-patches.mts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Guards pnpm package patches against unapproved additions.
|
| 4 |
+
import { execFileSync } from "node:child_process";
|
| 5 |
+
import fs from "node:fs";
|
| 6 |
+
import path from "node:path";
|
| 7 |
+
import { fileURLToPath } from "node:url";
|
| 8 |
+
import { asRecord } from "@openclaw/normalization-core/record-coerce";
|
| 9 |
+
import YAML from "yaml";
|
| 10 |
+
import { pnpmLockfileDocuments } from "./lib/pnpm-lockfile-documents.mjs";
|
| 11 |
+
|
| 12 |
+
const ALLOWED_PATCHED_DEPENDENCIES = new Map([
|
| 13 |
+
["@awesome.me/webawesome@3.12.0", "patches/@awesome.me__webawesome@3.12.0.patch"],
|
| 14 |
+
["@novnc/novnc@1.7.0", "patches/@novnc__novnc@1.7.0.patch"],
|
| 15 |
+
["vitest@5.0.0", "patches/vitest@5.0.0.patch"],
|
| 16 |
+
["baileys@7.0.0-rc12", "patches/baileys@7.0.0-rc12.patch"],
|
| 17 |
+
["baileys@7.0.0-rc13", "patches/baileys@7.0.0-rc13.patch"],
|
| 18 |
+
["matrix-js-sdk@42.3.0", "patches/matrix-js-sdk@42.3.0.patch"],
|
| 19 |
+
]);
|
| 20 |
+
|
| 21 |
+
const ALLOWED_PATCH_FILES = new Set(["patches/.gitkeep", ...ALLOWED_PATCHED_DEPENDENCIES.values()]);
|
| 22 |
+
|
| 23 |
+
type PackagePatchViolation = { file: string; kind: string; detail: string };
|
| 24 |
+
|
| 25 |
+
function listTrackedFiles(cwd: string, patterns: string[]) {
|
| 26 |
+
return execFileSync("git", ["ls-files", "-z", "--", ...patterns], {
|
| 27 |
+
cwd,
|
| 28 |
+
encoding: "utf8",
|
| 29 |
+
})
|
| 30 |
+
.split("\0")
|
| 31 |
+
.filter(Boolean)
|
| 32 |
+
.toSorted((left, right) => left.localeCompare(right));
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
function readRecordFile(cwd: string, relativePath: string, parse: (source: string) => unknown) {
|
| 36 |
+
const filePath = path.join(cwd, relativePath);
|
| 37 |
+
if (!fs.existsSync(filePath)) {
|
| 38 |
+
return {};
|
| 39 |
+
}
|
| 40 |
+
return asRecord(parse(fs.readFileSync(filePath, "utf8")));
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
function collectPatchedDependencyViolations(
|
| 44 |
+
file: string,
|
| 45 |
+
patchedDependencies: unknown,
|
| 46 |
+
violations: PackagePatchViolation[],
|
| 47 |
+
options: { allowAnyValueForLegacy?: boolean } = {},
|
| 48 |
+
) {
|
| 49 |
+
for (const [specifier, patchPathOrHash] of Object.entries(asRecord(patchedDependencies))) {
|
| 50 |
+
if (
|
| 51 |
+
options.allowAnyValueForLegacy === true
|
| 52 |
+
? ALLOWED_PATCHED_DEPENDENCIES.has(specifier)
|
| 53 |
+
: ALLOWED_PATCHED_DEPENDENCIES.get(specifier) === patchPathOrHash
|
| 54 |
+
) {
|
| 55 |
+
continue;
|
| 56 |
+
}
|
| 57 |
+
violations.push({
|
| 58 |
+
file,
|
| 59 |
+
kind: "patchedDependency",
|
| 60 |
+
detail: `${specifier} -> ${String(patchPathOrHash)}`,
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function collectWorkspacePatchViolations(cwd: string, violations: PackagePatchViolation[]) {
|
| 66 |
+
const workspace = readRecordFile(cwd, "pnpm-workspace.yaml", YAML.parse);
|
| 67 |
+
collectPatchedDependencyViolations(
|
| 68 |
+
"pnpm-workspace.yaml",
|
| 69 |
+
workspace.patchedDependencies,
|
| 70 |
+
violations,
|
| 71 |
+
);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function collectLockfilePatchViolations(cwd: string, violations: PackagePatchViolation[]) {
|
| 75 |
+
const filePath = path.join(cwd, "pnpm-lock.yaml");
|
| 76 |
+
if (!fs.existsSync(filePath)) {
|
| 77 |
+
return;
|
| 78 |
+
}
|
| 79 |
+
for (const document of Object.values(pnpmLockfileDocuments(fs.readFileSync(filePath, "utf8")))) {
|
| 80 |
+
if (document === null) {
|
| 81 |
+
continue;
|
| 82 |
+
}
|
| 83 |
+
const lockfile = asRecord(YAML.parse(document));
|
| 84 |
+
collectPatchedDependencyViolations("pnpm-lock.yaml", lockfile.patchedDependencies, violations, {
|
| 85 |
+
allowAnyValueForLegacy: true,
|
| 86 |
+
});
|
| 87 |
+
}
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
function collectPackageJsonPatchViolations(cwd: string, violations: PackagePatchViolation[]) {
|
| 91 |
+
for (const relativePath of listTrackedFiles(cwd, ["*package.json"])) {
|
| 92 |
+
const packageJson = readRecordFile(cwd, relativePath, JSON.parse);
|
| 93 |
+
const patchedDependencies = asRecord(asRecord(packageJson.pnpm).patchedDependencies);
|
| 94 |
+
for (const [specifier, patchPath] of Object.entries(patchedDependencies)) {
|
| 95 |
+
violations.push({
|
| 96 |
+
file: relativePath,
|
| 97 |
+
kind: "packageJsonPatchedDependency",
|
| 98 |
+
detail: `${specifier} -> ${String(patchPath)}`,
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function collectPatchFileViolations(cwd: string, violations: PackagePatchViolation[]) {
|
| 105 |
+
for (const relativePath of listTrackedFiles(cwd, ["*.patch"])) {
|
| 106 |
+
if (!fs.existsSync(path.join(cwd, relativePath))) {
|
| 107 |
+
continue;
|
| 108 |
+
}
|
| 109 |
+
if (ALLOWED_PATCH_FILES.has(relativePath)) {
|
| 110 |
+
continue;
|
| 111 |
+
}
|
| 112 |
+
violations.push({
|
| 113 |
+
file: relativePath,
|
| 114 |
+
kind: "patchFile",
|
| 115 |
+
detail: "new package patch file",
|
| 116 |
+
});
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Collects disallowed package patch declarations and patch files.
|
| 122 |
+
*/
|
| 123 |
+
export function collectPackagePatchViolations(cwd = process.cwd()) {
|
| 124 |
+
const violations: PackagePatchViolation[] = [];
|
| 125 |
+
collectWorkspacePatchViolations(cwd, violations);
|
| 126 |
+
collectLockfilePatchViolations(cwd, violations);
|
| 127 |
+
collectPackageJsonPatchViolations(cwd, violations);
|
| 128 |
+
collectPatchFileViolations(cwd, violations);
|
| 129 |
+
return violations;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/**
|
| 133 |
+
* Runs the package patch guard.
|
| 134 |
+
*/
|
| 135 |
+
export async function main() {
|
| 136 |
+
const violations = collectPackagePatchViolations();
|
| 137 |
+
if (violations.length === 0) {
|
| 138 |
+
process.stdout.write(
|
| 139 |
+
`PASS package patch guard: no new pnpm patches; ${ALLOWED_PATCHED_DEPENDENCIES.size} approved patches allowlisted.\n`,
|
| 140 |
+
);
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
console.error(
|
| 145 |
+
"FAIL package patch guard: new pnpm package patches are not allowed. Upstream the fix, publish a new package version, then bump the dependency instead.",
|
| 146 |
+
);
|
| 147 |
+
for (const violation of violations) {
|
| 148 |
+
console.error(`- ${violation.file}: ${violation.kind}: ${violation.detail}`);
|
| 149 |
+
}
|
| 150 |
+
process.exitCode = 1;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
| 154 |
+
main().catch((error: unknown) => {
|
| 155 |
+
console.error(error);
|
| 156 |
+
process.exit(1);
|
| 157 |
+
});
|
| 158 |
+
}
|
scripts/check-pairing-account-scope.mts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Checks pairing config logic for account-scoped allowlist handling.
|
| 4 |
+
import ts from "typescript";
|
| 5 |
+
import { createPairingGuardContext } from "./lib/pairing-guard-context.mts";
|
| 6 |
+
import {
|
| 7 |
+
collectFileViolations,
|
| 8 |
+
getPropertyNameText,
|
| 9 |
+
runAsScript,
|
| 10 |
+
toLine,
|
| 11 |
+
} from "./lib/ts-guard-utils.mts";
|
| 12 |
+
|
| 13 |
+
const { repoRoot, sourceRoots } = createPairingGuardContext(import.meta.url);
|
| 14 |
+
|
| 15 |
+
function isUndefinedLikeExpression(node: ts.Node) {
|
| 16 |
+
if (ts.isIdentifier(node) && node.text === "undefined") {
|
| 17 |
+
return true;
|
| 18 |
+
}
|
| 19 |
+
return node.kind === ts.SyntaxKind.NullKeyword;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
function hasRequiredAccountIdProperty(node: ts.Node) {
|
| 23 |
+
if (!ts.isObjectLiteralExpression(node)) {
|
| 24 |
+
return false;
|
| 25 |
+
}
|
| 26 |
+
for (const property of node.properties) {
|
| 27 |
+
if (ts.isShorthandPropertyAssignment(property) && property.name.text === "accountId") {
|
| 28 |
+
return true;
|
| 29 |
+
}
|
| 30 |
+
if (!ts.isPropertyAssignment(property)) {
|
| 31 |
+
continue;
|
| 32 |
+
}
|
| 33 |
+
if (getPropertyNameText(property.name) !== "accountId") {
|
| 34 |
+
continue;
|
| 35 |
+
}
|
| 36 |
+
if (isUndefinedLikeExpression(property.initializer)) {
|
| 37 |
+
return false;
|
| 38 |
+
}
|
| 39 |
+
return true;
|
| 40 |
+
}
|
| 41 |
+
return false;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
function findViolations(content: string, filePath: string) {
|
| 45 |
+
const sourceFile = ts.createSourceFile(filePath, content, ts.ScriptTarget.Latest, true);
|
| 46 |
+
const violations: { line: number; reason: string }[] = [];
|
| 47 |
+
|
| 48 |
+
const visit = (node: ts.Node): void => {
|
| 49 |
+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) {
|
| 50 |
+
const callName = node.expression.text;
|
| 51 |
+
if (callName === "readChannelAllowFromStore") {
|
| 52 |
+
const accountIdArgument = node.arguments[2];
|
| 53 |
+
if (!accountIdArgument || isUndefinedLikeExpression(accountIdArgument)) {
|
| 54 |
+
violations.push({
|
| 55 |
+
line: toLine(sourceFile, node),
|
| 56 |
+
reason: "readChannelAllowFromStore call must pass explicit accountId as 3rd arg",
|
| 57 |
+
});
|
| 58 |
+
}
|
| 59 |
+
} else if (callName === "upsertChannelPairingRequest") {
|
| 60 |
+
const firstArg = node.arguments[0];
|
| 61 |
+
if (!firstArg || !hasRequiredAccountIdProperty(firstArg)) {
|
| 62 |
+
violations.push({
|
| 63 |
+
line: toLine(sourceFile, node),
|
| 64 |
+
reason: "upsertChannelPairingRequest call must include accountId in params",
|
| 65 |
+
});
|
| 66 |
+
}
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
ts.forEachChild(node, visit);
|
| 70 |
+
};
|
| 71 |
+
|
| 72 |
+
visit(sourceFile);
|
| 73 |
+
return violations;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
async function main() {
|
| 77 |
+
const violations = await collectFileViolations({
|
| 78 |
+
sourceRoots,
|
| 79 |
+
repoRoot,
|
| 80 |
+
findViolations,
|
| 81 |
+
});
|
| 82 |
+
|
| 83 |
+
if (violations.length === 0) {
|
| 84 |
+
return;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
console.error("Found unscoped pairing-store calls:");
|
| 88 |
+
for (const violation of violations) {
|
| 89 |
+
console.error(`- ${violation.path}:${violation.line} (${violation.reason})`);
|
| 90 |
+
}
|
| 91 |
+
process.exit(1);
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
runAsScript(import.meta.url, main);
|
scripts/check-plugin-gateway-gauntlet.mts
ADDED
|
@@ -0,0 +1,1187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Runs plugin lifecycle and gateway QA gauntlet probes with timing metrics.
|
| 4 |
+
import type { StdioOptions } from "node:child_process";
|
| 5 |
+
import fs from "node:fs";
|
| 6 |
+
import os from "node:os";
|
| 7 |
+
import path from "node:path";
|
| 8 |
+
import process from "node:process";
|
| 9 |
+
import { fileURLToPath } from "node:url";
|
| 10 |
+
import {
|
| 11 |
+
MAX_TIMER_TIMEOUT_MS,
|
| 12 |
+
resolveTimerTimeoutMs,
|
| 13 |
+
} from "../packages/normalization-core/src/number-coercion.ts";
|
| 14 |
+
import { normalizeCsvOrLooseStringList } from "../packages/normalization-core/src/string-normalization.ts";
|
| 15 |
+
import { stripLeadingPackageManagerSeparator } from "./lib/arg-utils.mts";
|
| 16 |
+
import { hasUnjoinedWork, runManagedCommand } from "./lib/managed-child-process.mts";
|
| 17 |
+
import {
|
| 18 |
+
parseNonNegativeInt,
|
| 19 |
+
parsePositiveInt,
|
| 20 |
+
parsePositiveNumber,
|
| 21 |
+
} from "./lib/numeric-options.mjs";
|
| 22 |
+
import {
|
| 23 |
+
buildGauntletPrebuildEnv,
|
| 24 |
+
collectGatewayCpuObservations,
|
| 25 |
+
collectMetricObservations,
|
| 26 |
+
collectPluginsWithRequiredEntries,
|
| 27 |
+
collectRequiredPluginEntries,
|
| 28 |
+
collectQaBaselineRegressionObservations,
|
| 29 |
+
detectCommandDiagnosticFailure,
|
| 30 |
+
discoverBundledPluginManifests,
|
| 31 |
+
readQaSuiteSummary,
|
| 32 |
+
selectPluginEntries,
|
| 33 |
+
} from "./lib/plugin-gateway-gauntlet.mts";
|
| 34 |
+
// Termination tests import this entrypoint in a child before publishing readiness.
|
| 35 |
+
// Keep its record guard on the dependency-light script seam to avoid startup skew.
|
| 36 |
+
import { isRecord } from "./lib/record-shared.mjs";
|
| 37 |
+
|
| 38 |
+
const DEFAULT_QA_SCENARIOS = [
|
| 39 |
+
"channel-chat-baseline",
|
| 40 |
+
"memory-failure-fallback",
|
| 41 |
+
"gateway-restart-inflight-run",
|
| 42 |
+
];
|
| 43 |
+
const DEFAULT_CPU_CORE_WARN = 0.9;
|
| 44 |
+
const DEFAULT_HOT_WALL_WARN_MS = 30_000;
|
| 45 |
+
const DEFAULT_MAX_RSS_WARN_MB = 1536;
|
| 46 |
+
const DEFAULT_QA_PLUGIN_CHUNK_SIZE = 12;
|
| 47 |
+
const SINGLE_VALUE_FLAGS = new Set([
|
| 48 |
+
"--build-timeout-ms",
|
| 49 |
+
"--command-timeout-ms",
|
| 50 |
+
"--cpu-core-warn",
|
| 51 |
+
"--hot-wall-warn-ms",
|
| 52 |
+
"--limit",
|
| 53 |
+
"--max-rss-warn-mb",
|
| 54 |
+
"--output-dir",
|
| 55 |
+
"--qa-cpu-regression-multiplier",
|
| 56 |
+
"--qa-plugin-chunk-size",
|
| 57 |
+
"--qa-timeout-ms",
|
| 58 |
+
"--qa-wall-regression-multiplier",
|
| 59 |
+
"--repo-root",
|
| 60 |
+
"--rss-anomaly-multiplier",
|
| 61 |
+
"--shard-index",
|
| 62 |
+
"--shard-total",
|
| 63 |
+
"--wall-anomaly-multiplier",
|
| 64 |
+
]);
|
| 65 |
+
const COMMAND_OUTPUT_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
| 66 |
+
const ANSI_PATTERN = new RegExp(String.raw`\u001B\[[0-9;]*m`, "gu");
|
| 67 |
+
|
| 68 |
+
type ProcessSignal = `SIG${string}`;
|
| 69 |
+
type PluginEntry = ReturnType<typeof discoverBundledPluginManifests>[number];
|
| 70 |
+
type CommandAlias = PluginEntry["cliCommandAliases"][number];
|
| 71 |
+
export type GauntletMeasuredRow = {
|
| 72 |
+
label: string;
|
| 73 |
+
phase: string;
|
| 74 |
+
pluginId: string | null;
|
| 75 |
+
status: number;
|
| 76 |
+
diagnosticFailure: string | null;
|
| 77 |
+
diagnosticDetail?: string;
|
| 78 |
+
signal: ProcessSignal | null;
|
| 79 |
+
timedOut: boolean;
|
| 80 |
+
spawnError: { code: string | null; message: string } | null;
|
| 81 |
+
logPath: string | null;
|
| 82 |
+
logWriteError?: string;
|
| 83 |
+
qaSummaryPath?: string;
|
| 84 |
+
qaMetrics?: unknown;
|
| 85 |
+
} & ReturnType<typeof parseTimedMetrics>;
|
| 86 |
+
export type GauntletMeasuredCommandParams = {
|
| 87 |
+
args: string[];
|
| 88 |
+
command: string;
|
| 89 |
+
consoleOutputMaxBytes?: number;
|
| 90 |
+
cwd: string;
|
| 91 |
+
env: NodeJS.ProcessEnv;
|
| 92 |
+
label: string;
|
| 93 |
+
logDir: string;
|
| 94 |
+
maxBufferBytes?: number;
|
| 95 |
+
phase: string;
|
| 96 |
+
pluginId?: string;
|
| 97 |
+
spawnOptions?: { stdio?: StdioOptions };
|
| 98 |
+
timeoutKillGraceMs?: number;
|
| 99 |
+
timeoutMs: number;
|
| 100 |
+
timeMode?: "none";
|
| 101 |
+
};
|
| 102 |
+
type GauntletObservation = Record<string, unknown> & {
|
| 103 |
+
coldStart?: boolean;
|
| 104 |
+
kind?: string;
|
| 105 |
+
phase?: string;
|
| 106 |
+
};
|
| 107 |
+
type GauntletContext = {
|
| 108 |
+
repoRoot: string;
|
| 109 |
+
outputDir: string;
|
| 110 |
+
env: NodeJS.ProcessEnv;
|
| 111 |
+
matrix: PluginEntry[];
|
| 112 |
+
plugins: PluginEntry[];
|
| 113 |
+
rows: GauntletMeasuredRow[];
|
| 114 |
+
commandTimeoutMs: number;
|
| 115 |
+
skipSlashHelp: boolean;
|
| 116 |
+
includePluginOwnedCliAliases: boolean;
|
| 117 |
+
qaBaseline: boolean;
|
| 118 |
+
qaScenarios: string[];
|
| 119 |
+
qaPluginChunkSize: number;
|
| 120 |
+
qaTimeoutMs: number;
|
| 121 |
+
};
|
| 122 |
+
type QaSummary = NonNullable<ReturnType<typeof readQaSuiteSummary>["summary"]>;
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Parses plugin gateway gauntlet CLI arguments and env defaults.
|
| 126 |
+
*/
|
| 127 |
+
export function parseArgs(argv: string[]) {
|
| 128 |
+
const args = stripLeadingPackageManagerSeparator(argv);
|
| 129 |
+
const pluginIds: string[] = [];
|
| 130 |
+
const qaScenarios: string[] = [];
|
| 131 |
+
const options = {
|
| 132 |
+
repoRoot: process.cwd(),
|
| 133 |
+
outputDir: path.join(
|
| 134 |
+
process.cwd(),
|
| 135 |
+
".artifacts",
|
| 136 |
+
"plugin-gateway-gauntlet",
|
| 137 |
+
new Date().toISOString().replace(/[:.]/g, "-"),
|
| 138 |
+
),
|
| 139 |
+
pluginIds,
|
| 140 |
+
shardTotal: readOptionalPositiveIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL") ?? 1,
|
| 141 |
+
shardIndex: readOptionalNonNegativeIntEnv("OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX") ?? 0,
|
| 142 |
+
limit: undefined as number | undefined,
|
| 143 |
+
skipPrebuild: false,
|
| 144 |
+
skipLifecycle: false,
|
| 145 |
+
skipQa: false,
|
| 146 |
+
qaBaseline: false,
|
| 147 |
+
skipSlashHelp: false,
|
| 148 |
+
qaScenarios,
|
| 149 |
+
qaPluginChunkSize: DEFAULT_QA_PLUGIN_CHUNK_SIZE,
|
| 150 |
+
cpuCoreWarn: DEFAULT_CPU_CORE_WARN,
|
| 151 |
+
hotWallWarnMs: DEFAULT_HOT_WALL_WARN_MS,
|
| 152 |
+
maxRssWarnMb: DEFAULT_MAX_RSS_WARN_MB,
|
| 153 |
+
wallAnomalyMultiplier: 3,
|
| 154 |
+
rssAnomalyMultiplier: 2.5,
|
| 155 |
+
qaCpuRegressionMultiplier: 2,
|
| 156 |
+
qaWallRegressionMultiplier: 2,
|
| 157 |
+
commandTimeoutMs: 120_000,
|
| 158 |
+
buildTimeoutMs: 600_000,
|
| 159 |
+
qaTimeoutMs: 900_000,
|
| 160 |
+
allowEmpty: false,
|
| 161 |
+
failOnObservation: process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_FAIL_ON_OBSERVATION === "1",
|
| 162 |
+
keepRunRoot: process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_KEEP_RUN_ROOT === "1",
|
| 163 |
+
};
|
| 164 |
+
const envIds = normalizeCsvOrLooseStringList(process.env.OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS);
|
| 165 |
+
options.pluginIds.push(...envIds);
|
| 166 |
+
const seenSingleValueFlags = new Set<string>();
|
| 167 |
+
parseArgv: for (let index = 0; index < args.length; index += 1) {
|
| 168 |
+
const arg = args[index];
|
| 169 |
+
if (arg === undefined) {
|
| 170 |
+
break;
|
| 171 |
+
}
|
| 172 |
+
if (SINGLE_VALUE_FLAGS.has(arg)) {
|
| 173 |
+
if (seenSingleValueFlags.has(arg)) {
|
| 174 |
+
throw new Error(`${arg} was provided more than once`);
|
| 175 |
+
}
|
| 176 |
+
seenSingleValueFlags.add(arg);
|
| 177 |
+
}
|
| 178 |
+
const readValue = () => {
|
| 179 |
+
const value = args[index + 1];
|
| 180 |
+
if (!value || value.startsWith("-")) {
|
| 181 |
+
throw new Error(`Missing value for ${arg}`);
|
| 182 |
+
}
|
| 183 |
+
index += 1;
|
| 184 |
+
return value;
|
| 185 |
+
};
|
| 186 |
+
switch (arg) {
|
| 187 |
+
case "--":
|
| 188 |
+
break parseArgv;
|
| 189 |
+
case "--repo-root":
|
| 190 |
+
options.repoRoot = path.resolve(readValue());
|
| 191 |
+
break;
|
| 192 |
+
case "--output-dir":
|
| 193 |
+
options.outputDir = path.resolve(readValue());
|
| 194 |
+
break;
|
| 195 |
+
case "--plugin":
|
| 196 |
+
options.pluginIds.push(readValue());
|
| 197 |
+
break;
|
| 198 |
+
case "--shard-total":
|
| 199 |
+
options.shardTotal = parsePositiveInt(readValue(), "--shard-total");
|
| 200 |
+
break;
|
| 201 |
+
case "--shard-index":
|
| 202 |
+
options.shardIndex = parseNonNegativeInt(readValue(), "--shard-index");
|
| 203 |
+
break;
|
| 204 |
+
case "--limit":
|
| 205 |
+
options.limit = parsePositiveInt(readValue(), "--limit");
|
| 206 |
+
break;
|
| 207 |
+
case "--qa-scenario":
|
| 208 |
+
options.qaScenarios.push(readValue());
|
| 209 |
+
break;
|
| 210 |
+
case "--qa-plugin-chunk-size":
|
| 211 |
+
options.qaPluginChunkSize = parsePositiveInt(readValue(), "--qa-plugin-chunk-size");
|
| 212 |
+
break;
|
| 213 |
+
case "--qa-baseline":
|
| 214 |
+
options.qaBaseline = true;
|
| 215 |
+
break;
|
| 216 |
+
case "--cpu-core-warn":
|
| 217 |
+
options.cpuCoreWarn = parsePositiveNumber(readValue(), "--cpu-core-warn");
|
| 218 |
+
break;
|
| 219 |
+
case "--hot-wall-warn-ms":
|
| 220 |
+
options.hotWallWarnMs = parsePositiveInt(readValue(), "--hot-wall-warn-ms");
|
| 221 |
+
break;
|
| 222 |
+
case "--max-rss-warn-mb":
|
| 223 |
+
options.maxRssWarnMb = parsePositiveNumber(readValue(), "--max-rss-warn-mb");
|
| 224 |
+
break;
|
| 225 |
+
case "--wall-anomaly-multiplier":
|
| 226 |
+
options.wallAnomalyMultiplier = parsePositiveNumber(
|
| 227 |
+
readValue(),
|
| 228 |
+
"--wall-anomaly-multiplier",
|
| 229 |
+
);
|
| 230 |
+
break;
|
| 231 |
+
case "--rss-anomaly-multiplier":
|
| 232 |
+
options.rssAnomalyMultiplier = parsePositiveNumber(readValue(), "--rss-anomaly-multiplier");
|
| 233 |
+
break;
|
| 234 |
+
case "--qa-cpu-regression-multiplier":
|
| 235 |
+
options.qaCpuRegressionMultiplier = parsePositiveNumber(
|
| 236 |
+
readValue(),
|
| 237 |
+
"--qa-cpu-regression-multiplier",
|
| 238 |
+
);
|
| 239 |
+
break;
|
| 240 |
+
case "--qa-wall-regression-multiplier":
|
| 241 |
+
options.qaWallRegressionMultiplier = parsePositiveNumber(
|
| 242 |
+
readValue(),
|
| 243 |
+
"--qa-wall-regression-multiplier",
|
| 244 |
+
);
|
| 245 |
+
break;
|
| 246 |
+
case "--command-timeout-ms":
|
| 247 |
+
options.commandTimeoutMs = parsePositiveInt(readValue(), "--command-timeout-ms");
|
| 248 |
+
break;
|
| 249 |
+
case "--build-timeout-ms":
|
| 250 |
+
options.buildTimeoutMs = parsePositiveInt(readValue(), "--build-timeout-ms");
|
| 251 |
+
break;
|
| 252 |
+
case "--qa-timeout-ms":
|
| 253 |
+
options.qaTimeoutMs = parsePositiveInt(readValue(), "--qa-timeout-ms");
|
| 254 |
+
break;
|
| 255 |
+
case "--skip-prebuild":
|
| 256 |
+
options.skipPrebuild = true;
|
| 257 |
+
break;
|
| 258 |
+
case "--skip-lifecycle":
|
| 259 |
+
options.skipLifecycle = true;
|
| 260 |
+
break;
|
| 261 |
+
case "--skip-qa":
|
| 262 |
+
options.skipQa = true;
|
| 263 |
+
break;
|
| 264 |
+
case "--skip-slash-help":
|
| 265 |
+
options.skipSlashHelp = true;
|
| 266 |
+
break;
|
| 267 |
+
case "--keep-run-root":
|
| 268 |
+
options.keepRunRoot = true;
|
| 269 |
+
break;
|
| 270 |
+
case "--allow-empty":
|
| 271 |
+
options.allowEmpty = true;
|
| 272 |
+
break;
|
| 273 |
+
case "--fail-on-observation":
|
| 274 |
+
options.failOnObservation = true;
|
| 275 |
+
break;
|
| 276 |
+
case "--help":
|
| 277 |
+
printHelp();
|
| 278 |
+
process.exit(0);
|
| 279 |
+
break;
|
| 280 |
+
default:
|
| 281 |
+
throw new Error(`Unknown argument: ${arg}`);
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
if (options.qaScenarios.length === 0) {
|
| 285 |
+
options.qaScenarios = [...DEFAULT_QA_SCENARIOS];
|
| 286 |
+
}
|
| 287 |
+
assertNoDuplicateValues(options.pluginIds, "--plugin");
|
| 288 |
+
assertNoDuplicateValues(options.qaScenarios, "--qa-scenario");
|
| 289 |
+
return options;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function printHelp() {
|
| 293 |
+
console.log(`Usage: pnpm test:plugins:gateway-gauntlet [options]
|
| 294 |
+
|
| 295 |
+
Runs a shardable bundled-plugin lifecycle, slash inventory, and QA gateway perf gauntlet.
|
| 296 |
+
|
| 297 |
+
Options:
|
| 298 |
+
--plugin <id> Plugin id to include, repeatable
|
| 299 |
+
--shard-total <count> Total plugin shards (default: env or 1)
|
| 300 |
+
--shard-index <index> Zero-based shard index (default: env or 0)
|
| 301 |
+
--limit <count> Limit selected plugins after sharding
|
| 302 |
+
--output-dir <path> Artifact directory
|
| 303 |
+
--qa-scenario <id> QA Lab scenario id, repeatable
|
| 304 |
+
--qa-plugin-chunk-size <count> Plugins enabled per QA run (default: 12)
|
| 305 |
+
--qa-baseline Run a no-extra-plugin QA baseline before plugin chunks
|
| 306 |
+
--cpu-core-warn <ratio> Hot CPU threshold (default: 0.9)
|
| 307 |
+
--hot-wall-warn-ms <ms> Minimum wall time for hot CPU observations (default: 30000)
|
| 308 |
+
--max-rss-warn-mb <mb> Maximum RSS warning threshold (default: 1536)
|
| 309 |
+
--wall-anomaly-multiplier <n> Wall-time anomaly multiplier (default: 3)
|
| 310 |
+
--rss-anomaly-multiplier <n> RSS anomaly multiplier (default: 2.5)
|
| 311 |
+
--qa-cpu-regression-multiplier <n> QA baseline CPU regression multiplier (default: 2)
|
| 312 |
+
--qa-wall-regression-multiplier <n> QA baseline wall regression multiplier (default: 2)
|
| 313 |
+
--command-timeout-ms <ms> Lifecycle/slash command timeout (default: 120000)
|
| 314 |
+
--build-timeout-ms <ms> Prebuild command timeout (default: 600000)
|
| 315 |
+
--qa-timeout-ms <ms> QA chunk timeout (default: 900000)
|
| 316 |
+
--skip-prebuild Skip the upfront build used to avoid per-command rebuild noise
|
| 317 |
+
--skip-lifecycle Skip plugin install/inspect/disable/enable/doctor/uninstall
|
| 318 |
+
--skip-qa Skip QA Lab RPC conversation runs
|
| 319 |
+
--skip-slash-help Skip CLI help probes for plugin-declared command aliases
|
| 320 |
+
--allow-empty Allow zero-command runs when every active phase is skipped
|
| 321 |
+
--fail-on-observation Treat RSS/CPU/wall observation rows as guard failures
|
| 322 |
+
--keep-run-root Preserve isolated HOME/state/log temp root after success
|
| 323 |
+
|
| 324 |
+
Environment:
|
| 325 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_IDS Comma-separated plugin ids to include
|
| 326 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_TOTAL Total plugin shards
|
| 327 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_INDEX Zero-based shard index
|
| 328 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_FAIL_ON_OBSERVATION=1
|
| 329 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_KEEP_RUN_ROOT=1
|
| 330 |
+
OPENCLAW_PLUGIN_GATEWAY_GAUNTLET_QA_SUMMARY_MAX_BYTES QA summary read ceiling
|
| 331 |
+
`);
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function assertNoDuplicateValues(values: string[], label: string) {
|
| 335 |
+
const seen = new Set();
|
| 336 |
+
for (const value of values) {
|
| 337 |
+
const normalized = value.trim();
|
| 338 |
+
if (!normalized) {
|
| 339 |
+
continue;
|
| 340 |
+
}
|
| 341 |
+
if (seen.has(normalized)) {
|
| 342 |
+
throw new Error(`Duplicate ${label} value: ${normalized}`);
|
| 343 |
+
}
|
| 344 |
+
seen.add(normalized);
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
function readOptionalPositiveIntEnv(name: string) {
|
| 349 |
+
const raw = process.env[name];
|
| 350 |
+
return raw ? parsePositiveInt(raw, name) : undefined;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function readOptionalNonNegativeIntEnv(name: string) {
|
| 354 |
+
const raw = process.env[name];
|
| 355 |
+
return raw ? parseNonNegativeInt(raw, name) : undefined;
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function shouldPromoteObservationGuardFailure(observation: GauntletObservation) {
|
| 359 |
+
// Setup and the first cold work command are still reported, but they are not
|
| 360 |
+
// stable enough to fail the gauntlet's steady-state regression guard.
|
| 361 |
+
return observation?.phase !== "prebuild" && observation?.coldStart !== true;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
export function buildObservationGuardFailures(
|
| 365 |
+
observations: GauntletObservation[],
|
| 366 |
+
enabled = false,
|
| 367 |
+
) {
|
| 368 |
+
if (!enabled) {
|
| 369 |
+
return [];
|
| 370 |
+
}
|
| 371 |
+
return observations
|
| 372 |
+
.filter((observation) => shouldPromoteObservationGuardFailure(observation))
|
| 373 |
+
.map((observation) => ({
|
| 374 |
+
kind: `observation:${observation.kind ?? "unknown"}`,
|
| 375 |
+
message: `Gauntlet observation threshold exceeded: ${observation.kind ?? "unknown"}`,
|
| 376 |
+
observation,
|
| 377 |
+
}));
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/**
|
| 381 |
+
* Builds the command that prepares QA runtime artifacts before gauntlet probes.
|
| 382 |
+
*/
|
| 383 |
+
export function createGauntletPrebuildCommand(repoRoot: string) {
|
| 384 |
+
return {
|
| 385 |
+
command: process.execPath,
|
| 386 |
+
args: ["--import", "tsx", path.join(repoRoot, "scripts", "build-all.mts"), "qaRuntime"],
|
| 387 |
+
};
|
| 388 |
+
}
|
| 389 |
+
|
| 390 |
+
function openclawCommand(repoRoot: string, args: string[]) {
|
| 391 |
+
return {
|
| 392 |
+
command: process.execPath,
|
| 393 |
+
args: [path.join(repoRoot, "dist", "entry.js"), ...args],
|
| 394 |
+
};
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
function builtEntryPath(repoRoot: string) {
|
| 398 |
+
return path.join(repoRoot, "dist", "entry.js");
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
function selectSlashHelpAliases(plugin: PluginEntry, includePluginOwnedCliAliases: boolean) {
|
| 402 |
+
return includePluginOwnedCliAliases
|
| 403 |
+
? plugin.cliCommandAliases
|
| 404 |
+
: plugin.cliCommandAliases.filter((entry) => !isPluginOwnedCliAlias(entry));
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
function requiresBuiltEntry(options: ReturnType<typeof parseArgs>, selectedPlugins: PluginEntry[]) {
|
| 408 |
+
if (selectedPlugins.length === 0) {
|
| 409 |
+
return false;
|
| 410 |
+
}
|
| 411 |
+
if (!options.skipLifecycle) {
|
| 412 |
+
return true;
|
| 413 |
+
}
|
| 414 |
+
if (options.skipSlashHelp) {
|
| 415 |
+
return false;
|
| 416 |
+
}
|
| 417 |
+
return selectedPlugins.some((plugin) => selectSlashHelpAliases(plugin, true).length > 0);
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
function sourceOpenclawCommand(repoRoot: string, args: string[]) {
|
| 421 |
+
return {
|
| 422 |
+
command: process.execPath,
|
| 423 |
+
args: [path.join(repoRoot, "scripts", "run-node.mjs"), ...args],
|
| 424 |
+
};
|
| 425 |
+
}
|
| 426 |
+
|
| 427 |
+
function chunkArray<Value>(values: Value[], chunkSize: number) {
|
| 428 |
+
const chunks: Value[][] = [];
|
| 429 |
+
for (let index = 0; index < values.length; index += chunkSize) {
|
| 430 |
+
chunks.push(values.slice(index, index + chunkSize));
|
| 431 |
+
}
|
| 432 |
+
return chunks;
|
| 433 |
+
}
|
| 434 |
+
|
| 435 |
+
/**
|
| 436 |
+
* Converts an output path to a repo-relative path, rejecting paths outside the repo.
|
| 437 |
+
*/
|
| 438 |
+
export function toRepoRelativePath(repoRoot: string, absolutePath: string) {
|
| 439 |
+
const relativePath = path.relative(repoRoot, absolutePath);
|
| 440 |
+
if (!relativePath || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
| 441 |
+
throw new Error(`Output path must stay inside repo root: ${absolutePath}`);
|
| 442 |
+
}
|
| 443 |
+
return relativePath;
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
function validateOutputDir(options: ReturnType<typeof parseArgs>, repoRoot: string) {
|
| 447 |
+
if (!options.skipQa) {
|
| 448 |
+
toRepoRelativePath(repoRoot, path.join(options.outputDir, "qa-suite"));
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
function createIsolatedEnv(repoRoot: string, runRoot: string) {
|
| 453 |
+
const home = path.join(runRoot, "home");
|
| 454 |
+
const stateDir = path.join(runRoot, "state");
|
| 455 |
+
const configPath = path.join(stateDir, "openclaw.json");
|
| 456 |
+
fs.mkdirSync(home, { recursive: true });
|
| 457 |
+
fs.mkdirSync(stateDir, { recursive: true });
|
| 458 |
+
fs.writeFileSync(
|
| 459 |
+
configPath,
|
| 460 |
+
`${JSON.stringify({ logging: { file: path.join(runRoot, "logs", "openclaw.log") } })}\n`,
|
| 461 |
+
{ encoding: "utf8", mode: 0o600, flag: "wx" },
|
| 462 |
+
);
|
| 463 |
+
return {
|
| 464 |
+
...process.env,
|
| 465 |
+
HOME: home,
|
| 466 |
+
XDG_CONFIG_HOME: path.join(home, ".config"),
|
| 467 |
+
XDG_CACHE_HOME: path.join(home, ".cache"),
|
| 468 |
+
XDG_DATA_HOME: path.join(home, ".local", "share"),
|
| 469 |
+
OPENCLAW_STATE_DIR: stateDir,
|
| 470 |
+
OPENCLAW_CONFIG_PATH: configPath,
|
| 471 |
+
OPENCLAW_QA_SUITE_PROGRESS: process.env.OPENCLAW_QA_SUITE_PROGRESS ?? "1",
|
| 472 |
+
PATH: process.env.PATH,
|
| 473 |
+
PWD: repoRoot,
|
| 474 |
+
};
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
function hasUsrBinTime() {
|
| 478 |
+
return fs.existsSync("/usr/bin/time");
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
function timeWrapperArgs(command: string, args: string[]) {
|
| 482 |
+
if (!hasUsrBinTime()) {
|
| 483 |
+
return { command, args, mode: "none" };
|
| 484 |
+
}
|
| 485 |
+
if (process.platform === "darwin") {
|
| 486 |
+
return { command: "/usr/bin/time", args: ["-l", command, ...args], mode: "bsd" };
|
| 487 |
+
}
|
| 488 |
+
return { command: "/usr/bin/time", args: ["-v", command, ...args], mode: "gnu" };
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
/**
|
| 492 |
+
* Parses `/usr/bin/time` output into wall, CPU, and RSS metrics.
|
| 493 |
+
*/
|
| 494 |
+
export function parseTimedMetrics(stderr: string, wallMs: number, mode: string) {
|
| 495 |
+
let userSeconds: number | null = null;
|
| 496 |
+
let systemSeconds: number | null = null;
|
| 497 |
+
let maxRssMb: number | null = null;
|
| 498 |
+
if (mode === "gnu") {
|
| 499 |
+
userSeconds = parseLastFloat(stderr, /^\s*User time \(seconds\):\s*([0-9.]+)\s*$/gmu);
|
| 500 |
+
systemSeconds = parseLastFloat(stderr, /^\s*System time \(seconds\):\s*([0-9.]+)\s*$/gmu);
|
| 501 |
+
const maxRssKb = parseLastFloat(
|
| 502 |
+
stderr,
|
| 503 |
+
/^\s*Maximum resident set size \(kbytes\):\s*([0-9.]+)\s*$/gmu,
|
| 504 |
+
);
|
| 505 |
+
maxRssMb = maxRssKb == null ? null : maxRssKb / 1024;
|
| 506 |
+
} else if (mode === "bsd") {
|
| 507 |
+
const cpuLine = parseLastMatch(
|
| 508 |
+
stderr,
|
| 509 |
+
/^\s*[0-9.]+\s+real\s+([0-9.]+)\s+user\s+([0-9.]+)\s+sys\s*$/gmu,
|
| 510 |
+
);
|
| 511 |
+
userSeconds = parseMatchFloat(cpuLine, 1);
|
| 512 |
+
systemSeconds = parseMatchFloat(cpuLine, 2);
|
| 513 |
+
const maxRssBytes = parseLastFloat(stderr, /^\s*([0-9]+)\s+maximum resident set size\s*$/gmu);
|
| 514 |
+
maxRssMb = maxRssBytes == null ? null : maxRssBytes / 1024 / 1024;
|
| 515 |
+
}
|
| 516 |
+
const cpuMs =
|
| 517 |
+
userSeconds == null && systemSeconds == null
|
| 518 |
+
? null
|
| 519 |
+
: ((userSeconds ?? 0) + (systemSeconds ?? 0)) * 1000;
|
| 520 |
+
return {
|
| 521 |
+
wallMs,
|
| 522 |
+
cpuMs,
|
| 523 |
+
cpuCoreRatio: cpuMs == null || wallMs <= 0 ? null : cpuMs / wallMs,
|
| 524 |
+
maxRssMb,
|
| 525 |
+
};
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
function parseLastMatch(value: string, pattern: RegExp) {
|
| 529 |
+
let lastMatch: RegExpMatchArray | null = null;
|
| 530 |
+
for (const match of value.matchAll(pattern)) {
|
| 531 |
+
lastMatch = match;
|
| 532 |
+
}
|
| 533 |
+
return lastMatch;
|
| 534 |
+
}
|
| 535 |
+
|
| 536 |
+
function parseMatchFloat(match: RegExpMatchArray | null, index: number) {
|
| 537 |
+
if (!match) {
|
| 538 |
+
return null;
|
| 539 |
+
}
|
| 540 |
+
const parsed = Number(match[index]);
|
| 541 |
+
return Number.isFinite(parsed) ? parsed : null;
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
function parseLastFloat(value: string, pattern: RegExp) {
|
| 545 |
+
return parseMatchFloat(parseLastMatch(value, pattern), 1);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function stripAnsi(value: string) {
|
| 549 |
+
return value.replace(ANSI_PATTERN, "");
|
| 550 |
+
}
|
| 551 |
+
|
| 552 |
+
function resolveOptionalTimerTimeoutMs(valueMs: number | undefined) {
|
| 553 |
+
return valueMs === undefined || valueMs <= 0
|
| 554 |
+
? null
|
| 555 |
+
: resolveTimerTimeoutMs(valueMs, MAX_TIMER_TIMEOUT_MS);
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
function writeCommandLog(params: {
|
| 559 |
+
logDir: string;
|
| 560 |
+
label: string;
|
| 561 |
+
command: string[];
|
| 562 |
+
stdout: string;
|
| 563 |
+
stderr: string;
|
| 564 |
+
}) {
|
| 565 |
+
const { logDir, label, stdout, stderr } = params;
|
| 566 |
+
fs.mkdirSync(logDir, { recursive: true });
|
| 567 |
+
const safeLabel = label.replace(/[^a-zA-Z0-9_.-]+/gu, "_");
|
| 568 |
+
const logPath = path.join(logDir, `${safeLabel}.log`);
|
| 569 |
+
fs.writeFileSync(
|
| 570 |
+
logPath,
|
| 571 |
+
[`$ ${params.command.join(" ")}`, "", stripAnsi(stdout), stripAnsi(stderr)].join("\n"),
|
| 572 |
+
"utf8",
|
| 573 |
+
);
|
| 574 |
+
return logPath;
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
/**
|
| 578 |
+
* Runs one command with optional timing wrapper, bounded output, and log capture.
|
| 579 |
+
*/
|
| 580 |
+
export async function runMeasuredCommand(
|
| 581 |
+
params: GauntletMeasuredCommandParams,
|
| 582 |
+
): Promise<GauntletMeasuredRow> {
|
| 583 |
+
const { command, args, mode } =
|
| 584 |
+
params.timeMode === "none"
|
| 585 |
+
? { command: params.command, args: params.args, mode: "none" }
|
| 586 |
+
: timeWrapperArgs(params.command, params.args);
|
| 587 |
+
const started = performance.now();
|
| 588 |
+
let stdout = "";
|
| 589 |
+
let stderr = "";
|
| 590 |
+
let stdoutBytes = 0;
|
| 591 |
+
let stderrBytes = 0;
|
| 592 |
+
let stdoutRelayBytes = 0;
|
| 593 |
+
let stderrRelayBytes = 0;
|
| 594 |
+
let stdoutTruncated = false;
|
| 595 |
+
let stderrTruncated = false;
|
| 596 |
+
let stdoutRelayTruncated = false;
|
| 597 |
+
let stderrRelayTruncated = false;
|
| 598 |
+
let spawnError: GauntletMeasuredRow["spawnError"] = null;
|
| 599 |
+
let timedOut = false;
|
| 600 |
+
let exitStatus: number | null = null;
|
| 601 |
+
let exitSignal: ProcessSignal | null = null;
|
| 602 |
+
let parentTerminationSignal: NodeJS.Signals | undefined;
|
| 603 |
+
let commandError: unknown;
|
| 604 |
+
const maxBufferBytes = params.maxBufferBytes ?? COMMAND_OUTPUT_MAX_BUFFER_BYTES;
|
| 605 |
+
const maxRelayBytes = params.consoleOutputMaxBytes ?? maxBufferBytes;
|
| 606 |
+
const timeoutMs = resolveOptionalTimerTimeoutMs(params.timeoutMs);
|
| 607 |
+
const timeoutKillGraceMs = resolveTimerTimeoutMs(
|
| 608 |
+
params.timeoutKillGraceMs ?? 5_000,
|
| 609 |
+
MAX_TIMER_TIMEOUT_MS,
|
| 610 |
+
);
|
| 611 |
+
const appendCapturedOutput = (streamName: "stdout" | "stderr", chunk: string | Uint8Array) => {
|
| 612 |
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
| 613 |
+
const currentBytes = streamName === "stdout" ? stdoutBytes : stderrBytes;
|
| 614 |
+
const alreadyTruncated = streamName === "stdout" ? stdoutTruncated : stderrTruncated;
|
| 615 |
+
if (alreadyTruncated) {
|
| 616 |
+
return;
|
| 617 |
+
}
|
| 618 |
+
const remainingBytes = maxBufferBytes - currentBytes;
|
| 619 |
+
const appendTruncation = () => {
|
| 620 |
+
const message = `\n[${streamName} truncated after ${maxBufferBytes} bytes]\n`;
|
| 621 |
+
if (streamName === "stdout") {
|
| 622 |
+
stdout += message;
|
| 623 |
+
stdoutTruncated = true;
|
| 624 |
+
} else {
|
| 625 |
+
stderr += message;
|
| 626 |
+
stderrTruncated = true;
|
| 627 |
+
}
|
| 628 |
+
};
|
| 629 |
+
if (remainingBytes <= 0) {
|
| 630 |
+
appendTruncation();
|
| 631 |
+
return;
|
| 632 |
+
}
|
| 633 |
+
const capturedBuffer =
|
| 634 |
+
buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer;
|
| 635 |
+
if (streamName === "stdout") {
|
| 636 |
+
stdout += capturedBuffer.toString("utf8");
|
| 637 |
+
stdoutBytes += capturedBuffer.length;
|
| 638 |
+
} else {
|
| 639 |
+
stderr += capturedBuffer.toString("utf8");
|
| 640 |
+
stderrBytes += capturedBuffer.length;
|
| 641 |
+
}
|
| 642 |
+
if (buffer.length > remainingBytes) {
|
| 643 |
+
appendTruncation();
|
| 644 |
+
}
|
| 645 |
+
};
|
| 646 |
+
const relayOutput = (streamName: "stdout" | "stderr", chunk: string | Uint8Array) => {
|
| 647 |
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
| 648 |
+
const currentBytes = streamName === "stdout" ? stdoutRelayBytes : stderrRelayBytes;
|
| 649 |
+
const alreadyTruncated = streamName === "stdout" ? stdoutRelayTruncated : stderrRelayTruncated;
|
| 650 |
+
if (alreadyTruncated) {
|
| 651 |
+
return;
|
| 652 |
+
}
|
| 653 |
+
const write =
|
| 654 |
+
streamName === "stdout"
|
| 655 |
+
? process.stdout.write.bind(process.stdout)
|
| 656 |
+
: process.stderr.write.bind(process.stderr);
|
| 657 |
+
const markTruncated = () => {
|
| 658 |
+
write(`\n[${streamName} relay truncated after ${maxRelayBytes} bytes]\n`);
|
| 659 |
+
if (streamName === "stdout") {
|
| 660 |
+
stdoutRelayTruncated = true;
|
| 661 |
+
} else {
|
| 662 |
+
stderrRelayTruncated = true;
|
| 663 |
+
}
|
| 664 |
+
};
|
| 665 |
+
const remainingBytes = maxRelayBytes - currentBytes;
|
| 666 |
+
if (remainingBytes <= 0) {
|
| 667 |
+
markTruncated();
|
| 668 |
+
return;
|
| 669 |
+
}
|
| 670 |
+
const relayedBuffer =
|
| 671 |
+
buffer.length > remainingBytes ? buffer.subarray(0, remainingBytes) : buffer;
|
| 672 |
+
if (relayedBuffer.length > 0) {
|
| 673 |
+
write(relayedBuffer.toString("utf8"));
|
| 674 |
+
}
|
| 675 |
+
if (streamName === "stdout") {
|
| 676 |
+
stdoutRelayBytes += relayedBuffer.length;
|
| 677 |
+
} else {
|
| 678 |
+
stderrRelayBytes += relayedBuffer.length;
|
| 679 |
+
}
|
| 680 |
+
if (buffer.length > remainingBytes) {
|
| 681 |
+
markTruncated();
|
| 682 |
+
}
|
| 683 |
+
};
|
| 684 |
+
const appendOutput = (streamName: "stdout" | "stderr", chunk: string | Uint8Array) => {
|
| 685 |
+
relayOutput(streamName, chunk);
|
| 686 |
+
appendCapturedOutput(streamName, chunk);
|
| 687 |
+
};
|
| 688 |
+
try {
|
| 689 |
+
await runManagedCommand({
|
| 690 |
+
bin: command,
|
| 691 |
+
args,
|
| 692 |
+
cwd: params.cwd,
|
| 693 |
+
env: params.env,
|
| 694 |
+
shell: false,
|
| 695 |
+
stdio: (mode === "none" ? params.spawnOptions?.stdio : undefined) ?? "pipe",
|
| 696 |
+
timeoutMs: timeoutMs ?? undefined,
|
| 697 |
+
timeoutKillGraceMs,
|
| 698 |
+
signalKillGraceMs: timeoutKillGraceMs,
|
| 699 |
+
// Windows retains the managed owner's ordinary completion contract.
|
| 700 |
+
requireProcessTreeExit: process.platform !== "win32",
|
| 701 |
+
onSignal(signal) {
|
| 702 |
+
parentTerminationSignal ??= signal;
|
| 703 |
+
},
|
| 704 |
+
onReady(child) {
|
| 705 |
+
child.once("exit", (status, signal) => {
|
| 706 |
+
exitStatus = status;
|
| 707 |
+
exitSignal = signal;
|
| 708 |
+
});
|
| 709 |
+
child.stdout?.on("data", (chunk) => appendOutput("stdout", chunk));
|
| 710 |
+
child.stderr?.on("data", (chunk) => appendOutput("stderr", chunk));
|
| 711 |
+
},
|
| 712 |
+
});
|
| 713 |
+
} catch (error) {
|
| 714 |
+
commandError = error;
|
| 715 |
+
const code =
|
| 716 |
+
error && typeof error === "object" && "code" in error && typeof error.code === "string"
|
| 717 |
+
? error.code
|
| 718 |
+
: null;
|
| 719 |
+
spawnError = {
|
| 720 |
+
code,
|
| 721 |
+
message: error instanceof Error ? error.message : String(error),
|
| 722 |
+
};
|
| 723 |
+
timedOut = code === "ETIMEDOUT";
|
| 724 |
+
}
|
| 725 |
+
if (parentTerminationSignal && !hasUnjoinedWork(commandError)) {
|
| 726 |
+
// Preserve signal termination after cleanup, without admitting another probe.
|
| 727 |
+
const terminationSignal = parentTerminationSignal;
|
| 728 |
+
return await new Promise<never>(() => {
|
| 729 |
+
process.kill(process.pid, terminationSignal);
|
| 730 |
+
});
|
| 731 |
+
}
|
| 732 |
+
const wallMs = performance.now() - started;
|
| 733 |
+
const finalStatus =
|
| 734 |
+
spawnError && !timedOut ? 1 : (exitStatus ?? (exitSignal || spawnError ? 1 : 0));
|
| 735 |
+
const finalStderr = [
|
| 736 |
+
stderr,
|
| 737 |
+
spawnError ? `[spawn error] ${spawnError.code ?? "unknown"} ${spawnError.message}` : "",
|
| 738 |
+
]
|
| 739 |
+
.filter(Boolean)
|
| 740 |
+
.join("\n");
|
| 741 |
+
let logPath: string | null = null;
|
| 742 |
+
let logWriteError: string | null = null;
|
| 743 |
+
try {
|
| 744 |
+
logPath = writeCommandLog({
|
| 745 |
+
logDir: params.logDir,
|
| 746 |
+
label: params.label,
|
| 747 |
+
command: [params.command, ...params.args],
|
| 748 |
+
stdout,
|
| 749 |
+
stderr: finalStderr,
|
| 750 |
+
});
|
| 751 |
+
} catch (error) {
|
| 752 |
+
logWriteError = error instanceof Error ? error.message : String(error);
|
| 753 |
+
}
|
| 754 |
+
if (hasUnjoinedWork(commandError)) {
|
| 755 |
+
throw commandError;
|
| 756 |
+
}
|
| 757 |
+
const outputDiagnosticFailure = detectCommandDiagnosticFailure(stdout, finalStderr);
|
| 758 |
+
const diagnosticFailure =
|
| 759 |
+
outputDiagnosticFailure ?? (logWriteError ? "command-log-write-failure" : null);
|
| 760 |
+
return {
|
| 761 |
+
label: params.label,
|
| 762 |
+
phase: params.phase,
|
| 763 |
+
pluginId: params.pluginId ?? null,
|
| 764 |
+
status: logWriteError ? 1 : finalStatus,
|
| 765 |
+
diagnosticFailure,
|
| 766 |
+
signal: exitSignal,
|
| 767 |
+
timedOut,
|
| 768 |
+
spawnError,
|
| 769 |
+
logPath,
|
| 770 |
+
...(logWriteError ? { logWriteError } : {}),
|
| 771 |
+
...parseTimedMetrics(finalStderr, wallMs, mode),
|
| 772 |
+
};
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
/**
|
| 776 |
+
* Reports whether gauntlet result rows contain work beyond the prebuild step.
|
| 777 |
+
*/
|
| 778 |
+
export function hasGauntletWorkRows(rows: Array<Pick<GauntletMeasuredRow, "phase">>) {
|
| 779 |
+
return rows.some((row) => row.phase !== "prebuild");
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
function isPluginOwnedCliAlias(alias: CommandAlias) {
|
| 783 |
+
return alias.kind === "runtime-slash" && alias.cliCommand === alias.name;
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
function buildSlashHelpProbe(
|
| 787 |
+
params: GauntletContext & {
|
| 788 |
+
plugin: PluginEntry;
|
| 789 |
+
alias: CommandAlias;
|
| 790 |
+
},
|
| 791 |
+
) {
|
| 792 |
+
const command = params.alias.cliCommand ?? params.alias.name;
|
| 793 |
+
return {
|
| 794 |
+
cwd: params.repoRoot,
|
| 795 |
+
env: params.env,
|
| 796 |
+
logDir: path.join(params.outputDir, "logs", "slash-help"),
|
| 797 |
+
...openclawCommand(params.repoRoot, [command, "--help"]),
|
| 798 |
+
label: `${params.plugin.id}-slash-${params.alias.name}`,
|
| 799 |
+
phase: "slash:help",
|
| 800 |
+
pluginId: params.plugin.id,
|
| 801 |
+
timeoutMs: params.commandTimeoutMs,
|
| 802 |
+
};
|
| 803 |
+
}
|
| 804 |
+
|
| 805 |
+
async function runPluginLifecycleCommand(
|
| 806 |
+
params: GauntletContext & {
|
| 807 |
+
logPluginId: string;
|
| 808 |
+
label: string;
|
| 809 |
+
phase: string;
|
| 810 |
+
args: string[];
|
| 811 |
+
pluginId: string;
|
| 812 |
+
},
|
| 813 |
+
) {
|
| 814 |
+
process.stderr.write(`[plugin-gauntlet] ${params.logPluginId} ${params.phase}\n`);
|
| 815 |
+
params.rows.push(
|
| 816 |
+
await runMeasuredCommand({
|
| 817 |
+
cwd: params.repoRoot,
|
| 818 |
+
env: params.env,
|
| 819 |
+
logDir: path.join(params.outputDir, "logs", "lifecycle"),
|
| 820 |
+
...openclawCommand(params.repoRoot, ["plugins", ...params.args]),
|
| 821 |
+
label: params.label,
|
| 822 |
+
phase: `lifecycle:${params.phase}`,
|
| 823 |
+
pluginId: params.pluginId,
|
| 824 |
+
timeoutMs: params.commandTimeoutMs,
|
| 825 |
+
}),
|
| 826 |
+
);
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
async function runPluginLifecycle(params: GauntletContext) {
|
| 830 |
+
for (const plugin of params.plugins) {
|
| 831 |
+
const requiredPlugins = collectRequiredPluginEntries(params.matrix, [plugin]);
|
| 832 |
+
for (const requiredPlugin of requiredPlugins) {
|
| 833 |
+
await runPluginLifecycleCommand({
|
| 834 |
+
...params,
|
| 835 |
+
logPluginId: plugin.id,
|
| 836 |
+
label: `${plugin.id}-requires-${requiredPlugin.id}-install`,
|
| 837 |
+
phase: `requires:${requiredPlugin.id}:install`,
|
| 838 |
+
args: ["install", requiredPlugin.id],
|
| 839 |
+
pluginId: requiredPlugin.id,
|
| 840 |
+
});
|
| 841 |
+
}
|
| 842 |
+
type LifecycleCommand =
|
| 843 |
+
| { phase: string; args: string[] }
|
| 844 |
+
| { phase: string; alias: CommandAlias };
|
| 845 |
+
const commands = [
|
| 846 |
+
{
|
| 847 |
+
phase: "install",
|
| 848 |
+
args: ["install", plugin.id],
|
| 849 |
+
},
|
| 850 |
+
{ phase: "inspect", args: ["inspect", plugin.id, "--json"] },
|
| 851 |
+
...(params.skipSlashHelp
|
| 852 |
+
? []
|
| 853 |
+
: plugin.cliCommandAliases
|
| 854 |
+
.filter(isPluginOwnedCliAlias)
|
| 855 |
+
.map((alias) => ({ phase: `slash-help:${alias.name}`, alias }))),
|
| 856 |
+
{ phase: "disable", args: ["disable", plugin.id] },
|
| 857 |
+
...(plugin.hasRequiredConfigFields ? [] : [{ phase: "enable", args: ["enable", plugin.id] }]),
|
| 858 |
+
{ phase: "doctor", args: ["doctor"] },
|
| 859 |
+
{ phase: "uninstall", args: ["uninstall", plugin.id, "--force"] },
|
| 860 |
+
] satisfies LifecycleCommand[];
|
| 861 |
+
for (const command of commands) {
|
| 862 |
+
if ("alias" in command) {
|
| 863 |
+
process.stderr.write(`[plugin-gauntlet] ${plugin.id} ${command.phase}\n`);
|
| 864 |
+
params.rows.push(
|
| 865 |
+
await runMeasuredCommand({
|
| 866 |
+
...buildSlashHelpProbe({
|
| 867 |
+
...params,
|
| 868 |
+
plugin,
|
| 869 |
+
alias: command.alias,
|
| 870 |
+
}),
|
| 871 |
+
label: `${plugin.id}-${command.phase}`,
|
| 872 |
+
}),
|
| 873 |
+
);
|
| 874 |
+
continue;
|
| 875 |
+
}
|
| 876 |
+
await runPluginLifecycleCommand({
|
| 877 |
+
...params,
|
| 878 |
+
logPluginId: plugin.id,
|
| 879 |
+
label: `${plugin.id}-${command.phase}`,
|
| 880 |
+
phase: command.phase,
|
| 881 |
+
args: command.args,
|
| 882 |
+
pluginId: plugin.id,
|
| 883 |
+
});
|
| 884 |
+
}
|
| 885 |
+
for (const requiredPlugin of requiredPlugins.toReversed()) {
|
| 886 |
+
await runPluginLifecycleCommand({
|
| 887 |
+
...params,
|
| 888 |
+
logPluginId: plugin.id,
|
| 889 |
+
label: `${plugin.id}-requires-${requiredPlugin.id}-uninstall`,
|
| 890 |
+
phase: `requires:${requiredPlugin.id}:uninstall`,
|
| 891 |
+
args: ["uninstall", requiredPlugin.id, "--force"],
|
| 892 |
+
pluginId: requiredPlugin.id,
|
| 893 |
+
});
|
| 894 |
+
}
|
| 895 |
+
}
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
async function runSlashHelpProbes(params: GauntletContext) {
|
| 899 |
+
for (const plugin of params.plugins) {
|
| 900 |
+
const aliases = selectSlashHelpAliases(plugin, params.includePluginOwnedCliAliases);
|
| 901 |
+
for (const alias of aliases) {
|
| 902 |
+
process.stderr.write(`[plugin-gauntlet] ${plugin.id} slash-help /${alias.name}\n`);
|
| 903 |
+
params.rows.push(
|
| 904 |
+
await runMeasuredCommand({
|
| 905 |
+
...buildSlashHelpProbe({
|
| 906 |
+
...params,
|
| 907 |
+
plugin,
|
| 908 |
+
alias,
|
| 909 |
+
}),
|
| 910 |
+
}),
|
| 911 |
+
);
|
| 912 |
+
}
|
| 913 |
+
}
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
async function runQaChunks(params: GauntletContext) {
|
| 917 |
+
const chunks = [
|
| 918 |
+
...(params.qaBaseline ? [{ label: "baseline", plugins: [] }] : []),
|
| 919 |
+
...chunkArray(params.plugins, params.qaPluginChunkSize).map((plugins, index) => ({
|
| 920 |
+
label: `chunk-${String(index).padStart(2, "0")}`,
|
| 921 |
+
plugins,
|
| 922 |
+
})),
|
| 923 |
+
];
|
| 924 |
+
const summaries: QaSummary[] = [];
|
| 925 |
+
for (const [index, chunk] of chunks.entries()) {
|
| 926 |
+
const outputDir = path.join(params.outputDir, "qa-suite", chunk.label);
|
| 927 |
+
const outputArg = toRepoRelativePath(params.repoRoot, outputDir);
|
| 928 |
+
const pluginIds = chunk.plugins.map((plugin) => plugin.id);
|
| 929 |
+
const enabledPluginIds = collectPluginsWithRequiredEntries(params.matrix, chunk.plugins).map(
|
| 930 |
+
(plugin) => plugin.id,
|
| 931 |
+
);
|
| 932 |
+
const pluginIdLabel = pluginIds.length > 0 ? pluginIds.join(",") : "<baseline>";
|
| 933 |
+
process.stderr.write(
|
| 934 |
+
`[plugin-gauntlet] qa chunk ${index + 1}/${chunks.length}: ${pluginIdLabel}\n`,
|
| 935 |
+
);
|
| 936 |
+
const row = await runMeasuredCommand({
|
| 937 |
+
cwd: params.repoRoot,
|
| 938 |
+
env: params.env,
|
| 939 |
+
logDir: path.join(params.outputDir, "logs", "qa-suite"),
|
| 940 |
+
...sourceOpenclawCommand(params.repoRoot, [
|
| 941 |
+
"qa",
|
| 942 |
+
"suite",
|
| 943 |
+
"--provider-mode",
|
| 944 |
+
"mock-openai",
|
| 945 |
+
"--concurrency",
|
| 946 |
+
"1",
|
| 947 |
+
"--output-dir",
|
| 948 |
+
outputArg,
|
| 949 |
+
...params.qaScenarios.flatMap((scenario) => ["--scenario", scenario]),
|
| 950 |
+
...enabledPluginIds.flatMap((pluginId) => ["--enable-plugin", pluginId]),
|
| 951 |
+
]),
|
| 952 |
+
label: `qa-${chunk.label}`,
|
| 953 |
+
phase: "qa:rpc",
|
| 954 |
+
timeoutMs: params.qaTimeoutMs,
|
| 955 |
+
});
|
| 956 |
+
const summaryPath = path.join(outputDir, "qa-suite-summary.json");
|
| 957 |
+
const qaSummaryResult = readQaSuiteSummary(summaryPath);
|
| 958 |
+
const qaDiagnosticFailure =
|
| 959 |
+
row.status === 0 && !row.timedOut ? qaSummaryResult.diagnosticFailure : null;
|
| 960 |
+
params.rows.push({
|
| 961 |
+
...row,
|
| 962 |
+
pluginId: pluginIdLabel,
|
| 963 |
+
qaSummaryPath: summaryPath,
|
| 964 |
+
...(qaDiagnosticFailure ? { diagnosticFailure: qaDiagnosticFailure } : {}),
|
| 965 |
+
...(qaSummaryResult.diagnosticDetail
|
| 966 |
+
? { diagnosticDetail: qaSummaryResult.diagnosticDetail }
|
| 967 |
+
: {}),
|
| 968 |
+
...(qaSummaryResult.summary?.metrics ? { qaMetrics: qaSummaryResult.summary.metrics } : {}),
|
| 969 |
+
});
|
| 970 |
+
if (qaSummaryResult.summary) {
|
| 971 |
+
summaries.push(qaSummaryResult.summary);
|
| 972 |
+
}
|
| 973 |
+
}
|
| 974 |
+
return summaries;
|
| 975 |
+
}
|
| 976 |
+
|
| 977 |
+
async function main() {
|
| 978 |
+
const options = parseArgs(process.argv.slice(2));
|
| 979 |
+
const repoRoot = path.resolve(options.repoRoot);
|
| 980 |
+
validateOutputDir(options, repoRoot);
|
| 981 |
+
fs.mkdirSync(options.outputDir, { recursive: true });
|
| 982 |
+
const runRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-gauntlet-"));
|
| 983 |
+
let preserveRunRoot = options.keepRunRoot;
|
| 984 |
+
try {
|
| 985 |
+
const env = createIsolatedEnv(repoRoot, runRoot);
|
| 986 |
+
const matrix = discoverBundledPluginManifests(repoRoot);
|
| 987 |
+
const selectedPlugins = selectPluginEntries(matrix, {
|
| 988 |
+
ids: options.pluginIds,
|
| 989 |
+
shardTotal: options.shardTotal,
|
| 990 |
+
shardIndex: options.shardIndex,
|
| 991 |
+
limit: options.limit,
|
| 992 |
+
});
|
| 993 |
+
const selectedPluginsWithRequired = collectPluginsWithRequiredEntries(matrix, selectedPlugins);
|
| 994 |
+
const rows: GauntletMeasuredRow[] = [];
|
| 995 |
+
const commandEnv = buildGauntletPrebuildEnv(env, {
|
| 996 |
+
includePrivateQa: !options.skipQa,
|
| 997 |
+
buildIds: selectedPluginsWithRequired.map((plugin) => plugin.buildId),
|
| 998 |
+
skipDeclarationBuild: true,
|
| 999 |
+
});
|
| 1000 |
+
const context = {
|
| 1001 |
+
repoRoot,
|
| 1002 |
+
outputDir: options.outputDir,
|
| 1003 |
+
env: commandEnv,
|
| 1004 |
+
matrix,
|
| 1005 |
+
plugins: selectedPlugins,
|
| 1006 |
+
rows,
|
| 1007 |
+
commandTimeoutMs: options.commandTimeoutMs,
|
| 1008 |
+
skipSlashHelp: options.skipSlashHelp,
|
| 1009 |
+
includePluginOwnedCliAliases: options.skipLifecycle,
|
| 1010 |
+
qaBaseline: options.qaBaseline,
|
| 1011 |
+
qaScenarios: options.qaScenarios,
|
| 1012 |
+
qaPluginChunkSize: options.qaPluginChunkSize,
|
| 1013 |
+
qaTimeoutMs: options.qaTimeoutMs,
|
| 1014 |
+
};
|
| 1015 |
+
if (!options.skipPrebuild && (selectedPlugins.length > 0 || !options.skipQa)) {
|
| 1016 |
+
process.stderr.write("[plugin-gauntlet] prebuild\n");
|
| 1017 |
+
const prebuildCommand = createGauntletPrebuildCommand(repoRoot);
|
| 1018 |
+
rows.push(
|
| 1019 |
+
await runMeasuredCommand({
|
| 1020 |
+
cwd: repoRoot,
|
| 1021 |
+
env: commandEnv,
|
| 1022 |
+
logDir: path.join(options.outputDir, "logs", "prebuild"),
|
| 1023 |
+
command: prebuildCommand.command,
|
| 1024 |
+
args: prebuildCommand.args,
|
| 1025 |
+
label: "prebuild",
|
| 1026 |
+
phase: "prebuild",
|
| 1027 |
+
timeoutMs: options.buildTimeoutMs,
|
| 1028 |
+
}),
|
| 1029 |
+
);
|
| 1030 |
+
}
|
| 1031 |
+
const prebuildFailed = rows.some(
|
| 1032 |
+
(row) => row.phase === "prebuild" && (row.status !== 0 || row.timedOut),
|
| 1033 |
+
);
|
| 1034 |
+
const entryPath = builtEntryPath(repoRoot);
|
| 1035 |
+
const missingSkippedPrebuildEntry =
|
| 1036 |
+
selectedPlugins.length > 0 &&
|
| 1037 |
+
options.skipPrebuild &&
|
| 1038 |
+
requiresBuiltEntry(options, selectedPlugins) &&
|
| 1039 |
+
!fs.existsSync(entryPath);
|
| 1040 |
+
if (!prebuildFailed && !missingSkippedPrebuildEntry && !options.skipLifecycle) {
|
| 1041 |
+
await runPluginLifecycle(context);
|
| 1042 |
+
}
|
| 1043 |
+
if (!prebuildFailed && !missingSkippedPrebuildEntry && !options.skipSlashHelp) {
|
| 1044 |
+
await runSlashHelpProbes(context);
|
| 1045 |
+
}
|
| 1046 |
+
const qaSummaries = options.skipQa || prebuildFailed ? [] : await runQaChunks(context);
|
| 1047 |
+
const metricObservations = collectMetricObservations(rows, {
|
| 1048 |
+
cpuCoreWarn: options.cpuCoreWarn,
|
| 1049 |
+
hotWallWarnMs: options.hotWallWarnMs,
|
| 1050 |
+
maxRssWarnMb: options.maxRssWarnMb,
|
| 1051 |
+
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
|
| 1052 |
+
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
|
| 1053 |
+
});
|
| 1054 |
+
const qaBaselineObservations = collectQaBaselineRegressionObservations(rows, {
|
| 1055 |
+
cpuRegressionMultiplier: options.qaCpuRegressionMultiplier,
|
| 1056 |
+
wallRegressionMultiplier: options.qaWallRegressionMultiplier,
|
| 1057 |
+
});
|
| 1058 |
+
const gatewayObservations = qaSummaries.flatMap((qa) =>
|
| 1059 |
+
collectGatewayCpuObservations({
|
| 1060 |
+
startup: null,
|
| 1061 |
+
qa: isRecord(qa.metrics) ? { metrics: qa.metrics } : undefined,
|
| 1062 |
+
cpuCoreWarn: options.cpuCoreWarn,
|
| 1063 |
+
hotWallWarnMs: options.hotWallWarnMs,
|
| 1064 |
+
}),
|
| 1065 |
+
);
|
| 1066 |
+
const failures = rows.filter(
|
| 1067 |
+
(row) => row.status !== 0 || row.timedOut || row.diagnosticFailure,
|
| 1068 |
+
);
|
| 1069 |
+
const observations = [...metricObservations, ...qaBaselineObservations, ...gatewayObservations];
|
| 1070 |
+
const guardFailures: Array<{ kind: string; message: string }> = [];
|
| 1071 |
+
if (missingSkippedPrebuildEntry) {
|
| 1072 |
+
guardFailures.push({
|
| 1073 |
+
kind: "missing-built-entry",
|
| 1074 |
+
message:
|
| 1075 |
+
`${path.relative(repoRoot, entryPath)} is missing; ` +
|
| 1076 |
+
"run without --skip-prebuild or build the gauntlet runtime first.",
|
| 1077 |
+
});
|
| 1078 |
+
}
|
| 1079 |
+
if (!hasGauntletWorkRows(rows) && !options.allowEmpty && guardFailures.length === 0) {
|
| 1080 |
+
guardFailures.push({
|
| 1081 |
+
kind: "empty-run",
|
| 1082 |
+
message:
|
| 1083 |
+
"No lifecycle, slash-help, or QA gauntlet commands ran; remove a skip flag or pass --allow-empty for intentional dry runs.",
|
| 1084 |
+
});
|
| 1085 |
+
}
|
| 1086 |
+
guardFailures.push(...buildObservationGuardFailures(observations, options.failOnObservation));
|
| 1087 |
+
const hasFailures = failures.length > 0 || guardFailures.length > 0;
|
| 1088 |
+
preserveRunRoot = preserveRunRoot || hasFailures;
|
| 1089 |
+
let cleanupError = null;
|
| 1090 |
+
if (!preserveRunRoot) {
|
| 1091 |
+
try {
|
| 1092 |
+
fs.rmSync(runRoot, { recursive: true, force: true });
|
| 1093 |
+
} catch (error) {
|
| 1094 |
+
cleanupError = error instanceof Error ? error.message : String(error);
|
| 1095 |
+
preserveRunRoot = true;
|
| 1096 |
+
}
|
| 1097 |
+
}
|
| 1098 |
+
const summary = {
|
| 1099 |
+
generatedAt: new Date().toISOString(),
|
| 1100 |
+
repoRoot,
|
| 1101 |
+
outputDir: options.outputDir,
|
| 1102 |
+
isolatedRunRoot: runRoot,
|
| 1103 |
+
isolatedRunRootPreserved: preserveRunRoot,
|
| 1104 |
+
isolatedRunRootCleanupError: cleanupError,
|
| 1105 |
+
selectedPluginCount: selectedPlugins.length,
|
| 1106 |
+
totalPluginCount: matrix.length,
|
| 1107 |
+
options: {
|
| 1108 |
+
pluginIds: options.pluginIds,
|
| 1109 |
+
shardTotal: options.shardTotal,
|
| 1110 |
+
shardIndex: options.shardIndex,
|
| 1111 |
+
limit: options.limit ?? null,
|
| 1112 |
+
qaScenarios: options.qaScenarios,
|
| 1113 |
+
qaPluginChunkSize: options.qaPluginChunkSize,
|
| 1114 |
+
qaBaseline: options.qaBaseline,
|
| 1115 |
+
allowEmpty: options.allowEmpty,
|
| 1116 |
+
failOnObservation: options.failOnObservation,
|
| 1117 |
+
keepRunRoot: options.keepRunRoot,
|
| 1118 |
+
skipLifecycle: options.skipLifecycle,
|
| 1119 |
+
skipQa: options.skipQa,
|
| 1120 |
+
skipSlashHelp: options.skipSlashHelp,
|
| 1121 |
+
skipPrebuild: options.skipPrebuild,
|
| 1122 |
+
thresholds: {
|
| 1123 |
+
cpuCoreWarn: options.cpuCoreWarn,
|
| 1124 |
+
hotWallWarnMs: options.hotWallWarnMs,
|
| 1125 |
+
maxRssWarnMb: options.maxRssWarnMb,
|
| 1126 |
+
wallAnomalyMultiplier: options.wallAnomalyMultiplier,
|
| 1127 |
+
rssAnomalyMultiplier: options.rssAnomalyMultiplier,
|
| 1128 |
+
qaCpuRegressionMultiplier: options.qaCpuRegressionMultiplier,
|
| 1129 |
+
qaWallRegressionMultiplier: options.qaWallRegressionMultiplier,
|
| 1130 |
+
},
|
| 1131 |
+
},
|
| 1132 |
+
matrix,
|
| 1133 |
+
selectedPlugins,
|
| 1134 |
+
rows,
|
| 1135 |
+
observations,
|
| 1136 |
+
failures,
|
| 1137 |
+
guardFailures,
|
| 1138 |
+
};
|
| 1139 |
+
const summaryPath = path.join(options.outputDir, "plugin-gateway-gauntlet-summary.json");
|
| 1140 |
+
fs.writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, "utf8");
|
| 1141 |
+
process.stdout.write(`[plugin-gauntlet] summary: ${summaryPath}\n`);
|
| 1142 |
+
process.stdout.write(
|
| 1143 |
+
`[plugin-gauntlet] plugins=${selectedPlugins.length}/${matrix.length} rows=${rows.length} failures=${failures.length} observations=${summary.observations.length}\n`,
|
| 1144 |
+
);
|
| 1145 |
+
if (preserveRunRoot) {
|
| 1146 |
+
process.stdout.write(`[plugin-gauntlet] isolated run root preserved: ${runRoot}\n`);
|
| 1147 |
+
}
|
| 1148 |
+
for (const failure of failures) {
|
| 1149 |
+
process.stdout.write(
|
| 1150 |
+
`[plugin-gauntlet] failure phase=${failure.phase} plugin=${failure.pluginId ?? "<none>"} status=${failure.status} timedOut=${failure.timedOut} diagnostic=${failure.diagnosticFailure ?? ""} wallMs=${Math.round(failure.wallMs)} log=${failure.logPath}\n`,
|
| 1151 |
+
);
|
| 1152 |
+
}
|
| 1153 |
+
for (const failure of guardFailures) {
|
| 1154 |
+
process.stdout.write(`[plugin-gauntlet] failure ${failure.kind}: ${failure.message}\n`);
|
| 1155 |
+
}
|
| 1156 |
+
for (const observation of summary.observations.slice(0, 20)) {
|
| 1157 |
+
process.stdout.write(`[plugin-gauntlet] observation ${JSON.stringify(observation)}\n`);
|
| 1158 |
+
}
|
| 1159 |
+
if (hasFailures) {
|
| 1160 |
+
process.exitCode = 1;
|
| 1161 |
+
}
|
| 1162 |
+
} catch (error) {
|
| 1163 |
+
const unjoined = hasUnjoinedWork(error);
|
| 1164 |
+
if (unjoined) {
|
| 1165 |
+
process.stderr.write(`[plugin-gauntlet] isolated run root preserved: ${runRoot}\n`);
|
| 1166 |
+
}
|
| 1167 |
+
if (!options.keepRunRoot && !unjoined) {
|
| 1168 |
+
try {
|
| 1169 |
+
fs.rmSync(runRoot, { recursive: true, force: true });
|
| 1170 |
+
} catch (cleanupError) {
|
| 1171 |
+
process.stderr.write(
|
| 1172 |
+
`[plugin-gauntlet] failed to clean isolated run root ${runRoot}: ${
|
| 1173 |
+
cleanupError instanceof Error ? cleanupError.message : String(cleanupError)
|
| 1174 |
+
}\n`,
|
| 1175 |
+
);
|
| 1176 |
+
}
|
| 1177 |
+
}
|
| 1178 |
+
throw error;
|
| 1179 |
+
}
|
| 1180 |
+
}
|
| 1181 |
+
|
| 1182 |
+
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
| 1183 |
+
main().catch((error: unknown) => {
|
| 1184 |
+
console.error(error instanceof Error ? error.message : String(error));
|
| 1185 |
+
process.exitCode = 1;
|
| 1186 |
+
});
|
| 1187 |
+
}
|
scripts/check-plugin-sdk-wildcard-reexports.mts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env node
|
| 2 |
+
|
| 3 |
+
// Rejects wildcard plugin SDK re-exports in extension API barrels.
|
| 4 |
+
import {
|
| 5 |
+
createExtensionWildcardReexportScanner,
|
| 6 |
+
type ExtensionWildcardReexportPolicy,
|
| 7 |
+
} from "./lib/extension-wildcard-reexport-scanner.mts";
|
| 8 |
+
|
| 9 |
+
const WILDCARD_PLUGIN_SDK_REEXPORT_PATTERN =
|
| 10 |
+
/^\s*export\s+(?:type\s+)?\*\s+(?:as\s+[$\w]+\s+)?from\s+["']openclaw\/plugin-sdk\//u;
|
| 11 |
+
const policy = {
|
| 12 |
+
// SDK wildcard exposure is only a public extension-root barrel policy.
|
| 13 |
+
fileScope: "extension-root-api-files",
|
| 14 |
+
pattern: WILDCARD_PLUGIN_SDK_REEXPORT_PATTERN,
|
| 15 |
+
successMessage: "No plugin-sdk wildcard re-exports found in extension API barrels.",
|
| 16 |
+
findingsMessage: "Found plugin-sdk wildcard re-exports in extension API barrels:",
|
| 17 |
+
remediationMessage: "Use explicit named exports from the narrow SDK subpath instead.",
|
| 18 |
+
} satisfies ExtensionWildcardReexportPolicy;
|
| 19 |
+
const scanner = createExtensionWildcardReexportScanner(policy);
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Finds wildcard plugin SDK re-export lines in an extension API barrel.
|
| 23 |
+
*/
|
| 24 |
+
export const findPluginSdkWildcardReexports = scanner.findLines;
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Runs the plugin SDK wildcard re-export guard.
|
| 28 |
+
*/
|
| 29 |
+
export const main = scanner.main;
|
| 30 |
+
|
| 31 |
+
await scanner.exitIfMain(import.meta.url);
|