mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into worktree-export-jsdoc-gate
# Conflicts: # docs/development.i18n.yaml # docs/rfc/INDEX.md
This commit is contained in:
176
.github/workflows/ci.yml
vendored
176
.github/workflows/ci.yml
vendored
@@ -9,14 +9,99 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
|
||||
jobs:
|
||||
checks:
|
||||
node-24:
|
||||
runs-on: ubuntu-latest
|
||||
name: node 24 / ${{ matrix.lane }}
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: ${{ matrix.gate_concurrency }}
|
||||
DSH_PUBLINT_CONCURRENCY: ${{ matrix.publint_concurrency }}
|
||||
DSH_COVERAGE_MAX_WORKERS: ${{ matrix.coverage_max_workers }}
|
||||
DSH_ESLINT_CACHE: ${{ matrix.eslint_cache }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
name: node ${{ matrix.node }}
|
||||
include:
|
||||
- lane: static
|
||||
command: pnpm run check:ci:static
|
||||
gate_concurrency: '4'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
- lane: lint
|
||||
command: pnpm run check:ci:lint
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: '1'
|
||||
- lane: coverage
|
||||
command: pnpm run check:ci:coverage
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: '4'
|
||||
eslint_cache: ''
|
||||
- lane: snapshot
|
||||
command: pnpm run check:ci:snapshot
|
||||
gate_concurrency: '1'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
- lane: artifacts
|
||||
command: pnpm run check:ci:artifacts
|
||||
gate_concurrency: '3'
|
||||
publint_concurrency: '8'
|
||||
coverage_max_workers: ''
|
||||
eslint_cache: ''
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.PRIMARY_NODE_VERSION }}
|
||||
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- uses: actions/cache@v4
|
||||
if: matrix.lane == 'lint'
|
||||
with:
|
||||
path: .cache/eslint
|
||||
key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'tsconfig.json', 'packages/*/*/tsconfig.json', 'examples/*/tsconfig.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-eslint-
|
||||
|
||||
- name: Run gates
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
node-compat:
|
||||
runs-on: ubuntu-latest
|
||||
name: node 26
|
||||
env:
|
||||
DSH_GATE_CONCURRENCY: '2'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [26]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -27,78 +112,19 @@ jobs:
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-${{ matrix.node }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-${{ matrix.node }}-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Constraints
|
||||
run: pnpm run constraints
|
||||
|
||||
# Before lint: root typecheck validates the package/vendor reference graph
|
||||
# and refreshes TSC intermediates so type-aware ESLint sees the same project
|
||||
# boundaries as the build.
|
||||
- name: Typecheck (src + tests + examples)
|
||||
run: pnpm run typecheck
|
||||
|
||||
# Type-aware ESLint loads every package tsconfig through the project
|
||||
# service and peaks at ~3.4GB; the default V8 old-space ceiling (~2GB)
|
||||
# OOMs it (exit 134). Raise the ceiling well above the peak.
|
||||
- name: Lint
|
||||
run: pnpm run lint
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
|
||||
# Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the
|
||||
# fenced ts blocks against the root project-reference graph. The cordis
|
||||
# catalog freshness check, type-equiv check, Mermaid syntax check, and
|
||||
# markdown wrap/link checks only read source. Same `doc-sync` script the pre-push hook runs
|
||||
# (quality-gates RFC: one source of truth).
|
||||
- name: Doc-sync gates (doc code blocks + catalogs + mermaid + markdown)
|
||||
run: pnpm run doc-sync
|
||||
|
||||
# Module-graph freshness: regenerate docs/module-graph.md from the
|
||||
# packages' peerDependencies and fail if it differs from the committed
|
||||
# file. Only reads source package.json — no build needed.
|
||||
- name: Module-graph freshness
|
||||
run: pnpm run verify-module-graph
|
||||
|
||||
- name: Tests with coverage gate (per-file 100%)
|
||||
run: pnpm run test:coverage
|
||||
|
||||
# ACP snapshot tests (acp-snapshot-tests RFC): boot the real acp-agent
|
||||
# subprocess and replay recorded session-log fixtures, diffing the
|
||||
# normalized stdout transcript + re-persisted log against committed
|
||||
# goldens. KEYLESS by design — the same `test:snapshot` script the pre-push
|
||||
# hook runs (one source of truth), so the full-transcript regression net
|
||||
# is part of every PR gate, not just local pre-push.
|
||||
- name: Snapshot tests (ACP transcript replay)
|
||||
run: pnpm run test:snapshot
|
||||
|
||||
# Before hygiene: publint validates the packed artifacts (lib/index.js),
|
||||
# which only the tsdown bundling step emits, and verify-node-next-types
|
||||
# validates the built declarations.
|
||||
- name: Build (tsc -b + tsdown bundles)
|
||||
run: pnpm run build
|
||||
|
||||
- name: Hygiene (knip + publint + constraints + NodeNext types)
|
||||
run: pnpm run hygiene
|
||||
|
||||
- name: Demo smoke test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
out=$(printf 'echo ci smoke\n' | timeout 60 pnpm run demo:echo 2>&1)
|
||||
echo "$out"
|
||||
echo "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})'
|
||||
echo "$out" | grep -q '\[tool result\] ECHO: CI SMOKE'
|
||||
# The JSONL backend (root ./.sessions, no cwd → _no-cwd bucket) writes a
|
||||
# per-run session log named main-session-<uuid>.jsonl. Assert one exists.
|
||||
ls .sessions/_no-cwd/main-session-*.jsonl >/dev/null
|
||||
rm -rf .sessions
|
||||
|
||||
# The published `bin` is `lib/bin.js`, run under plain `node` by a real
|
||||
# consumer — NOT the tsx dev path the demo smoke and demo:* scripts use.
|
||||
# These keyless smokes boot the BUILT bins (this step runs AFTER the build)
|
||||
# in a temp dir that mirrors a real install, catching a regression in the
|
||||
# published artifact that tsx would mask. They self-skip if lib/ is absent,
|
||||
# so the e2e job (which does not build) does not run them.
|
||||
- name: Built-bin smoke test (published lib/bin.js under node)
|
||||
run: pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts
|
||||
- name: Run compatibility gates
|
||||
run: pnpm run check:node-compat
|
||||
|
||||
16
.github/workflows/e2e.yml
vendored
16
.github/workflows/e2e.yml
vendored
@@ -54,8 +54,8 @@ jobs:
|
||||
if: >-
|
||||
github.event_name != 'pull_request'
|
||||
|| !(github.event.pull_request.head.repo.fork || github.event.pull_request.user.login == 'dependabot[bot]')
|
||||
# Serial files (fileParallelism: false), 120s/test, retry 2. 45m bounds a
|
||||
# wedged run while leaving headroom for retry storms against a slow API.
|
||||
# Bounded file parallelism (DSH_E2E_MAX_WORKERS), 120s/test, retry 2. 45m
|
||||
# still bounds retry storms against a slow API while the happy path fans out.
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -67,6 +67,17 @@ jobs:
|
||||
- name: Enable corepack (pnpm)
|
||||
run: corepack enable
|
||||
|
||||
- name: Resolve pnpm store path
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-node-24-pnpm-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-24-pnpm-
|
||||
|
||||
- name: Install (immutable)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -97,4 +108,5 @@ jobs:
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
|
||||
DEEPSEEK_BASE_URL: https://api.deepseek.com
|
||||
DSH_E2E_MAX_WORKERS: 14
|
||||
run: pnpm run test:e2e
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ lib/
|
||||
*.tsbuildinfo
|
||||
pnpm-debug.log
|
||||
.pnpm-store/
|
||||
.cache/
|
||||
examples/*/*.jsonl
|
||||
.sessions/
|
||||
examples/*/.sessions/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGENTS.md
|
||||
|
||||
This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing anything under `packages/` — the service map, event surface, loop lifecycle, and extension seams. The documentation standard is [docs/AGENTS.md](docs/AGENTS.md).
|
||||
This is the monorepo of the DeepSeek Harness group; it hosts **DeepSeek Harness SDK**, a plugin-based SDK for building agent harnesses. The codebase is built on the vendored Cordis framework, microkernel-style: **everything is a plugin**. Read [docs/architecture.md](docs/architecture.md) before changing `packages/`; the documentation standard is [docs/AGENTS.md](docs/AGENTS.md).
|
||||
|
||||
## Pre-release stance: foundation over blast radius
|
||||
|
||||
@@ -52,7 +52,7 @@ pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY)
|
||||
|
||||
### Run the CI gates locally before marking a PR ready
|
||||
|
||||
From a fresh clone or worktree, `pnpm run build` first — publint and the NodeNext check validate built `lib/`. The CI-equivalent run:
|
||||
During implementation, run the narrowest affected checks; run this full CI-equivalent sequence only when complete and before marking a PR ready. From a fresh clone/worktree, `pnpm run build` first because publint and NodeNext validate built `lib/`:
|
||||
|
||||
```sh
|
||||
set -euo pipefail
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
development.md: 578fa619b8b3c7f5a306c8ad9cd880ab648b9b0c
|
||||
development.zh.md: e0189ae9b4c42f3f61ccc30b9205ff11f64f1fec
|
||||
development.md: 9f63dafdaa533d6340440ab7b7cb2b793ff1d10c
|
||||
development.zh.md: cabfc2dc10a63bc15bdea0bf740044e61acd4480
|
||||
|
||||
@@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook is configured in `lefthook.yml` as an early local checkpoint before review:
|
||||
|
||||
- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard.
|
||||
- `pre-push` runs `pnpm run test`, `pnpm run test:snapshot`, `pnpm run hygiene`, `pnpm run doc-sync`, and `pnpm run verify-module-graph`.
|
||||
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs unit tests, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
|
||||
|
||||
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
|
||||
|
||||
@@ -67,22 +67,9 @@ These hooks do not exactly mirror CI. Notably, `pre-push` runs unit tests withou
|
||||
|
||||
## CI gates
|
||||
|
||||
The GitHub workflow runs these gates on each pull request:
|
||||
The keyless GitHub workflow has six jobs: five Node 24 lanes run static gates, lint, coverage, snapshot replay, and artifact gates separately, and the Node 26 compatibility job runs `pnpm run check:node-compat`. The lane schedulers fan out independent gates from `package.json`: constraints, typecheck, lint, coverage, snapshot replay, `doc-sync` members, module-graph freshness, `knip`, and the echo-agent smoke test.
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- `pnpm run constraints`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run lint`
|
||||
- `pnpm run doc-sync`
|
||||
- `pnpm run verify-module-graph`
|
||||
- `pnpm run test:coverage`
|
||||
- `pnpm run test:snapshot`
|
||||
- `pnpm run build`
|
||||
- `pnpm run hygiene`
|
||||
- an echo-agent smoke test that checks the demo's tool call, tool result, and JSONL output
|
||||
- built-bin smoke tests that run the published `lib/bin.js` entrypoints under plain `node`
|
||||
|
||||
`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`.
|
||||
`pnpm run build` feeds the artifact lane, and `publint`, `verify-node-next-types`, and built-bin smoke tests wait for build output. The separate real-API workflow runs `pnpm run test:e2e` with a secret and `DSH_E2E_MAX_WORKERS=14`.
|
||||
|
||||
## Daily commands
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional
|
||||
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
|
||||
|
||||
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest 守卫。
|
||||
- `pre-push` 运行 `pnpm run test`、`pnpm run test:snapshot`、`pnpm run hygiene`、`pnpm run doc-sync` 和 `pnpm run verify-module-graph`。
|
||||
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行单元测试、快照测试、build、module graph 新鲜度,以及 `pnpm run hygiene` 和 `pnpm run doc-sync` 的成员门禁。
|
||||
|
||||
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。编辑 vendor 代码前先看 `vendor/README.md`。
|
||||
|
||||
@@ -67,22 +67,9 @@ vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `v
|
||||
|
||||
## CI 门禁
|
||||
|
||||
GitHub 工作流在每个 pull request 上运行这些门禁:
|
||||
keyless GitHub 工作流有六个 job:五个 Node 24 lane 分别运行 static gates、lint、coverage、snapshot replay 和 artifact gates,Node 26 兼容性 job 运行 `pnpm run check:node-compat`。各 lane 调度器并发运行来自 `package.json` 的独立门禁:constraints、typecheck、lint、coverage、snapshot replay、`doc-sync` 成员、module graph 新鲜度、`knip` 和 echo-agent 冒烟测试。
|
||||
|
||||
- `pnpm install --frozen-lockfile`
|
||||
- `pnpm run constraints`
|
||||
- `pnpm run typecheck`
|
||||
- `pnpm run lint`
|
||||
- `pnpm run doc-sync`
|
||||
- `pnpm run verify-module-graph`
|
||||
- `pnpm run test:coverage`
|
||||
- `pnpm run test:snapshot`
|
||||
- `pnpm run build`
|
||||
- `pnpm run hygiene`
|
||||
- 一个 echo-agent 冒烟测试,检查演示的工具调用、工具结果和 JSONL 输出
|
||||
- built-bin 冒烟测试,用纯 `node` 运行发布产物 `lib/bin.js` 入口
|
||||
|
||||
`pnpm run hygiene` 是 `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types` 的本地简写;CI 还会把 `pnpm run constraints` 作为更早的快速失败步骤单独跑一次,然后在 `pnpm run build` 之后跑完整的 hygiene 脚本。
|
||||
`pnpm run build` 供给 artifact lane,`publint`、`verify-node-next-types` 和 built-bin 冒烟测试等待 build 输出。单独的真实 API 工作流带密钥运行 `pnpm run test:e2e`,并设置 `DSH_E2E_MAX_WORKERS=14`。
|
||||
|
||||
## 日常命令
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand;
|
||||
| [Generated persistence log event catalog](implemented/process/2026-07-04-persistence-log-catalog.md) | 2026-07-04 |
|
||||
| [One gated in-file format for RFCs](implemented/process/2026-07-05-uniform-rfc-format.md) | 2026-07-05 |
|
||||
| [Export-surface JSDoc gate](implemented/process/2026-07-06-export-surface-jsdoc-gate.md) | 2026-07-06 |
|
||||
| [Parallel GitHub CI gates](implemented/process/2026-07-06-parallel-github-ci-gates.md) | 2026-07-06 |
|
||||
| [Parallel pre-push gates](implemented/process/2026-07-06-parallel-pre-push-gates.md) | 2026-07-06 |
|
||||
|
||||
### Testing
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# RFC: Parallel GitHub CI gates
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The keyless GitHub CI gates are mostly orthogonal: typecheck, lint, documentation freshness, coverage, snapshot replay, build, package-publication hygiene, demo smoke, and built-bin smoke fail for different reasons and do not need each other's runtime state. Running them as one ordered command chain makes the workflow wall clock equal the sum of those gates, while splitting every leaf gate into its own GitHub job repeats checkout, Node setup, pnpm restore, and install work until orchestration overhead becomes the bottleneck.
|
||||
|
||||
The hard part is the artifact boundary. `publint`, `verify-node-next-types`, and built-bin smoke tests need the built `lib/` outputs, while most gates only need source and dependencies. A blind fan-out either races those artifact consumers before `pnpm run build` has emitted declarations and bundles, or repeats the build in every artifact-dependent job.
|
||||
|
||||
## Decision
|
||||
|
||||
[CI](../../../../.github/workflows/ci.yml) keeps the keyless workflow to a few broad jobs instead of one job per gate. The Node 24 matrix has five lanes: static gates (`pnpm run check:ci:static`), lint (`pnpm run check:ci:lint`), coverage (`pnpm run check:ci:coverage`), snapshot replay (`pnpm run check:ci:snapshot`), and artifact gates (`pnpm run check:ci:artifacts`). The Node 26 compatibility job installs once and runs `pnpm run check:node-compat`.
|
||||
|
||||
Each lane delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), an in-process scheduler with bounded concurrency (`DSH_GATE_CONCURRENCY`). The static lane fans out constraints, the echo-agent demo smoke, `doc-sync` leaf gates, module-graph freshness, and `knip`; the lint lane runs ESLint with its own Node heap cap and a content-strategy ESLint cache; the coverage lane runs Vitest coverage with bounded file workers (`DSH_COVERAGE_MAX_WORKERS`); the snapshot lane isolates replay; the artifact lane builds once and then fans out the artifact consumers; the Node 26 compatibility job owns the TypeScript typecheck. The scheduler buffers each gate's output and prints a named result block with duration, so independent failures stay attributable inside each broad job log.
|
||||
|
||||
Generated `.sessions/` logs and `.doc-typecheck-*` temp directories are ignored by lint. The aggregate local CI mode still runs demo smoke after lint, while the split GitHub static lane can run demo smoke directly because lint is isolated in its own lane.
|
||||
|
||||
Build output is produced once inside the Node 24 artifact lane. The artifact consumers (`publint`, `verify-node-next-types`, and built-bin smoke) declare a dependency on `build`, so there is no upload/download handoff and no consumer can race ahead of declarations or bundles. The CI coverage reporter is text-only while local coverage keeps the HTML report.
|
||||
|
||||
Both CI workflows cache the pnpm store after enabling Corepack. The real-API e2e workflow also uses the shared `vitest.e2e.config.ts` bounded file pool (`DSH_E2E_MAX_WORKERS=14` in CI), so its speedup comes from dependency-cache reuse plus lower-level test-file fan-out instead of a separate GitHub job split.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the full serial chain in a Node matrix** - simplest to reason about, but it duplicates repo-wide gates that do not produce Node-version-specific signal and leaves every PR waiting for the sum of all gates.
|
||||
- **Run every gate as a separate GitHub job** - maximizes GitHub-visible fan-out, but it creates too many checks and pays repeated setup/install overhead for gates whose runtime is shorter than the runner preparation.
|
||||
- **Upload build artifacts to artifact-dependent jobs** - preserves correctness across many jobs, but it adds artifact upload/download time and keeps the workflow wide when the artifact consumers can run behind a local dependency in the primary job.
|
||||
- **Run `typecheck` and `build` concurrently** - exposes more work to the scheduler, but both commands invoke `tsc -b`; sharing incremental build state between them is a needless race for a small wall-clock gain.
|
||||
- **Use unbounded real-API e2e parallelism** - rejected because the suite includes many live model/tool scenarios; the worker pool needs an explicit `DSH_E2E_MAX_WORKERS` cap so CI and local runs can fan out without hiding quota or resource problems behind flaky rate-limit failures.
|
||||
|
||||
## Consequences
|
||||
|
||||
PR feedback arrives as a few GitHub checks with structured per-gate log blocks inside each broad job. That keeps runner setup overhead bounded and the Actions UI compact, at the cost of losing one status check per leaf gate.
|
||||
|
||||
The broad-lane split repeats checkout, setup, and install more often than a single primary job. That setup cost is intentional: on GitHub's hosted runner, running lint, coverage, and snapshot replay in one process pool oversubscribes CPU badly enough that the single-job critical path is longer than the repeated setup.
|
||||
|
||||
The split introduces a maintenance obligation: when `package.json` adds or removes a gate that belongs in CI, [scripts/run-gates.ts](../../../../scripts/run-gates.ts) needs the matching leaf. That obligation is intentional because the runner is the parallel execution plan for the same gate vocabulary, not a separate quality policy.
|
||||
|
||||
The Node 26 signal is narrower than the primary Node 24 signal. It proves the source graph on the newer runtime without doubling documentation, coverage, publication, snapshot, and smoke checks whose failures are not expected to vary by Node minor version.
|
||||
@@ -0,0 +1,40 @@
|
||||
# RFC: Parallel pre-push gates
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
The pre-push hook is the last local checkpoint before a branch leaves the machine, so its wall clock directly shapes whether contributors keep it enabled and trust its signal. Lefthook already runs top-level jobs in parallel, but aggregate jobs such as `pnpm run hygiene` and `pnpm run doc-sync` hide long sequential chains inside one job. The hook can therefore be configured as parallel while still waiting on serial subcommands whose members are independent.
|
||||
|
||||
Flattening those members directly into `lefthook.yml` solves the local hook only. CI has the same scheduling problem, and duplicating a long leaf list in YAML gives future script changes two places to drift.
|
||||
|
||||
`publint` has the same shape one level lower. Each package is linted independently against its own manifest and built output, but the runner loops through every package in order. On this repo that makes one package-publication gate consume time proportional to the number of packages even though the checks do not share mutable state.
|
||||
|
||||
## Decision
|
||||
|
||||
[lefthook.yml](../../../../lefthook.yml) keeps one pre-push job named `full check` and runs `pnpm run check:pre-push`. That package script delegates to [scripts/run-gates.ts](../../../../scripts/run-gates.ts), the same bounded scheduler CI uses.
|
||||
|
||||
The `pre-push` mode expands into leaf gates for the unit suite, snapshot suite, build, `hygiene` members, `doc-sync` members, and module-graph freshness. The leaf list keeps the same gate vocabulary as the package scripts, including RFC classification and RFC format, while the runner schedules independent checks concurrently and prints one timing/output block per gate.
|
||||
|
||||
The build gate makes the hook self-contained from a clean worktree. `publint` and `verify-node-next-types` wait for that build output, while source-only gates continue in parallel.
|
||||
|
||||
[scripts/publint-all.ts](../../../../scripts/publint-all.ts) discovers the package list from `packages/<group>/<pkg>` and runs `publint` with a worker pool sized from `availableParallelism()`. `DSH_PUBLINT_CONCURRENCY` can cap or raise the worker count for local machines and CI runners with different resource profiles. Results are buffered per package and printed in deterministic package order, so parallel execution does not scramble each package's log block.
|
||||
|
||||
The aggregate package scripts remain the source of truth for ad hoc local runs. The scheduler is a parallel execution plan over their member gates, not a replacement vocabulary.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep aggregate `hygiene` and `doc-sync` jobs in the hook** - simpler config, but it leaves most of the pre-push wall clock inside serial command chains that lefthook cannot see or schedule.
|
||||
- **Declare one lefthook job per leaf gate** - exposes parallelism through lefthook's native job model, but it makes the hook file carry a long member list that CI cannot reuse.
|
||||
- **Require developers to build before pushing** - avoids one hook gate, but it makes `publint` fail in a clean worktree and turns the final local checkpoint into a convention instead of a runnable check.
|
||||
- **Background subcommands inside shell scripts** - can parallelize work, but it loses lefthook's job names, per-job timing, and failure grouping, and makes signal handling harder to reason about.
|
||||
- **Declare one publint lefthook job per package** - exposes maximum parallelism, but it turns the hook into a hand-maintained package inventory that drifts exactly when new packages are added.
|
||||
- **Run publint with unbounded concurrency** - minimizes elapsed time on small machines only by gambling with process count, memory pressure, package tarball creation, and readable logs.
|
||||
|
||||
## Consequences
|
||||
|
||||
The hook's critical path becomes the slowest real gate instead of the sum of hidden gate chains. Lefthook reports one `full check` job, and the runner reports per-gate timing inside that job, so a slow local checkpoint still points at the gate that dominates the run.
|
||||
|
||||
The hook file stays short, and the duplicated member list lives in [scripts/run-gates.ts](../../../../scripts/run-gates.ts), where CI and pre-push can share it. The cost is a custom scheduler script instead of pure lefthook configuration, plus a build in the local pre-push path.
|
||||
|
||||
`publint-all.ts` becomes asynchronous code and buffers command output instead of inheriting stdio live. The payoff is package-level parallelism with stable output order and one environment variable for resource tuning.
|
||||
@@ -12,7 +12,7 @@ This RFC records the decision to add a **second, secret-consuming workflow** tha
|
||||
|
||||
## Decision
|
||||
|
||||
Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. ci.yml is left untouched.
|
||||
Add a dedicated workflow, [.github/workflows/e2e.yml](../../../../.github/workflows/e2e.yml), separate from ci.yml. It runs only `pnpm run test:e2e` against the external API using a repo secret, on trusted events, with a preflight that converts a missing secret into a loud failure instead of a false green. The keyless workflow remains separate so forkable quality gates and secret-consuming real-API gates keep different trigger and credential policies.
|
||||
|
||||
### A separate workflow, not a job in ci.yml
|
||||
|
||||
@@ -20,7 +20,7 @@ ci.yml's value is that it is keyless, forkable, and always-green: any contributo
|
||||
|
||||
### Cost is not the constraint; reliability is
|
||||
|
||||
The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all six `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy.
|
||||
The usual reason to ration real-API CI — token cost — does not apply here: we are DeepSeek and internal inference is effectively free. So the design optimizes for *coverage and signal*, not for minimizing calls. The suite runs in full (all matching `*.e2e.ts` files), on multiple triggers, on every trusted PR. This is the CI embodiment of the [docs/testing.md](../../../testing.md) with-key policy.
|
||||
|
||||
### Triggers: trusted events only
|
||||
|
||||
@@ -54,7 +54,7 @@ The repo secret is named `DEEPSEEK_API_KEY_EXTERNAL`; it is mapped to the `DEEPS
|
||||
|
||||
### Scope, runtime shape
|
||||
|
||||
Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's `[24, 26]` matrix already owns; a second Node version would double real-API calls for no added signal. `timeout-minutes: 45` bounds a wedged run given serial files (`fileParallelism: false`), 120s/test, and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled.
|
||||
Run **only** `test:e2e`. The keyless gates (typecheck/lint/coverage/snapshot/build/hygiene) already run in ci.yml on every push and PR; repeating them here would duplicate signal and slow the real-API job. No build step — e2e tests run unbuilt via tsx + the tsconfig paths map. Single Node 24 (the `engines` floor): these tests exercise *API integration*, not node-version compat, which ci.yml's Node 24/26 jobs already own; a second Node version would double real-API calls for no added signal. `vitest.e2e.config.ts` runs files through a bounded worker pool (`DSH_E2E_MAX_WORKERS`, default `4`, CI value `14`) so CI and local with-key runs parallelize independent files while retaining a one-line serial escape hatch for quota investigations. `timeout-minutes: 45` bounds a wedged run given 120s/test and `retry: 2`. `cancel-in-progress` is enabled only for `pull_request` runs — a superseded PR run is on a stale commit and worth cancelling, whereas a push/schedule run is already producing the post-merge/nightly signal and is never cancelled.
|
||||
|
||||
## Security
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export default tseslint.config(
|
||||
ignores: [
|
||||
'**/lib/**',
|
||||
'**/node_modules/**',
|
||||
'**/.sessions/**',
|
||||
'**/.doc-typecheck-*/**',
|
||||
'vendor/**', // vendored source keeps upstream style and idioms
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
|
||||
@@ -92,6 +92,46 @@ function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawn
|
||||
let spawned: Spawned | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
function hasStdoutLine(out: string[]): boolean {
|
||||
return out.join('').split('\n').some(line => line.trim().length > 0)
|
||||
}
|
||||
|
||||
async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise<void> {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout)
|
||||
child.stdout.off('data', onData)
|
||||
child.off('exit', onExit)
|
||||
child.off('error', onError)
|
||||
}
|
||||
const pass = () => {
|
||||
cleanup()
|
||||
resolve()
|
||||
}
|
||||
const fail = (reason: string) => {
|
||||
cleanup()
|
||||
reject(new Error(`${reason}; stderr: ${stderr.join('')}`))
|
||||
}
|
||||
const onData = () => {
|
||||
if (hasStdoutLine(out)) pass()
|
||||
}
|
||||
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
|
||||
fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`)
|
||||
}
|
||||
const onError = (error: Error) => {
|
||||
fail(`ACP child failed before emitting a stdout frame: ${error.message}`)
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`)
|
||||
}, timeoutMs)
|
||||
|
||||
child.stdout.on('data', onData)
|
||||
child.on('exit', onExit)
|
||||
child.on('error', onError)
|
||||
onData()
|
||||
})
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
if (spawned) {
|
||||
spawned.child.kill('SIGKILL')
|
||||
@@ -114,16 +154,21 @@ describe('acp-agent over real stdio (no key required)', () => {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
const out: string[] = []
|
||||
const stderr: string[] = []
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (c: string) => out.push(c))
|
||||
child.stderr.on('data', (c: string) => stderr.push(c))
|
||||
|
||||
// Send a single initialize request as a newline-delimited JSON-RPC frame.
|
||||
const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } })
|
||||
child.stdin.write(req + '\n')
|
||||
|
||||
// Give it a moment to boot + reply, then inspect stdout.
|
||||
await new Promise(r => setTimeout(r, 4000))
|
||||
child.kill('SIGKILL')
|
||||
try {
|
||||
await waitForStdoutLine(child, out, stderr, 15_000)
|
||||
} finally {
|
||||
child.kill('SIGKILL')
|
||||
}
|
||||
|
||||
const lines = out.join('').split('\n').filter(l => l.trim().length > 0)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
|
||||
@@ -43,8 +43,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
|
||||
// A handful of files for the model to read, so multiple bash steps
|
||||
// accumulate surface nodes (tool calls + results) and grow the history past
|
||||
// the (deliberately tiny) window.
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(40))
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
await writeFile(join(workdir, `file${i}.txt`), `This is file number ${i}. `.repeat(50))
|
||||
}
|
||||
|
||||
// Tiny window so a couple of steps crosses the threshold. The generation
|
||||
@@ -55,21 +55,21 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
|
||||
ctx = await codingHarness(workdir, {
|
||||
persona: SYSTEM_PROMPT,
|
||||
compact: {
|
||||
contextWindow: 2400,
|
||||
contextWindow: 2000,
|
||||
thresholdRatio: 0.5,
|
||||
retainTokens: 500,
|
||||
retainTokens: 400,
|
||||
summarizationModel: '',
|
||||
maxTokens: 2048,
|
||||
maxTokens: 1024,
|
||||
compactionRetries: 1,
|
||||
},
|
||||
persistenceRoot: './.sessions',
|
||||
persistenceRoot: join(workdir, '.sessions'),
|
||||
})
|
||||
const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{
|
||||
type: 'text',
|
||||
text: 'Read file1.txt, file2.txt, file3.txt, file4.txt, file5.txt, and file6.txt one at a '
|
||||
+ 'time using cat (a separate bash command for each). After reading all six, tell me how '
|
||||
text: 'Read file1.txt, file2.txt, file3.txt, and file4.txt one at a '
|
||||
+ 'time using cat (a separate bash command for each). After reading all four, tell me how '
|
||||
+ 'many files you read and the number mentioned in file1.txt.',
|
||||
}])
|
||||
await waitForIdle(ctx, agent)
|
||||
@@ -98,9 +98,9 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa
|
||||
expect(summaryData.shadowedSeqs.length).toBeGreaterThan(0)
|
||||
|
||||
// The conversation survived compaction: the agent produced a final answer
|
||||
// that reflects the work (it read six files).
|
||||
// that reflects the work (it read four files).
|
||||
const answer = finalText(events).toLowerCase()
|
||||
expect(answer.length).toBeGreaterThan(0)
|
||||
expect(answer).toMatch(/\b(6|six)\b/)
|
||||
expect(answer).toMatch(/\b(4|four)\b/)
|
||||
}, 240_000)
|
||||
})
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -9,6 +12,7 @@ import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
// Always dispose the harness, even on failure/retry/timeout: agent-loop
|
||||
@@ -16,11 +20,14 @@ afterEach(async () => {
|
||||
// process the model left behind.
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bash tool', () => {
|
||||
it('runs a bash command on request and reports its output', async () => {
|
||||
ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT })
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-'))
|
||||
ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }])
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
@@ -10,15 +13,19 @@ import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts'
|
||||
*/
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await ctx?.fiber.dispose()
|
||||
ctx = undefined
|
||||
if (workdir !== undefined) await rm(workdir, { recursive: true, force: true })
|
||||
workdir = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a plan', () => {
|
||||
it('appends a todo/write event with the model-produced task list', async () => {
|
||||
ctx = await codingHarness(process.cwd(), { persona: TODO_SYSTEM_PROMPT })
|
||||
workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-'))
|
||||
ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT })
|
||||
const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' })
|
||||
|
||||
agent.send([{ type: 'text', text:
|
||||
|
||||
17
lefthook.yml
17
lefthook.yml
@@ -20,19 +20,6 @@ pre-commit:
|
||||
run: scripts/check-vendor-manifest.sh
|
||||
|
||||
pre-push:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: test
|
||||
run: pnpm run test
|
||||
|
||||
- name: snapshot
|
||||
run: pnpm run test:snapshot
|
||||
|
||||
- name: hygiene
|
||||
run: pnpm run hygiene
|
||||
|
||||
- name: doc-sync
|
||||
run: pnpm run doc-sync
|
||||
|
||||
- name: module-graph freshness
|
||||
run: pnpm run verify-module-graph
|
||||
- name: full check
|
||||
run: pnpm run check:pre-push
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
||||
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
|
||||
"test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",
|
||||
"check:ci": "tsx scripts/run-gates.ts ci-primary",
|
||||
"check:ci:static": "tsx scripts/run-gates.ts ci-static",
|
||||
"check:ci:lint": "tsx scripts/run-gates.ts ci-lint",
|
||||
"check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage",
|
||||
"check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot",
|
||||
"check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts",
|
||||
"check:node-compat": "tsx scripts/run-gates.ts node-compat",
|
||||
"check:pre-push": "tsx scripts/run-gates.ts pre-push",
|
||||
"knip": "knip --treat-config-hints-as-errors",
|
||||
"publint": "tsx scripts/publint-all.ts",
|
||||
"doc-typecheck": "tsx scripts/doc-typecheck.ts",
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { existsSync, readdirSync } from 'node:fs'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const CONCURRENCY_ENV = 'DSH_PUBLINT_CONCURRENCY'
|
||||
|
||||
// publint every harness package. Packages live at packages/<group>/<pkg>
|
||||
// (the group dirs — core/llm/bash/… — are pure containers); vendor/ is private
|
||||
@@ -9,15 +14,94 @@ import { resolve } from 'node:path'
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const packagesRoot = resolve(root, 'packages')
|
||||
|
||||
const packages = readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter(group => group.isDirectory())
|
||||
.flatMap(group =>
|
||||
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
|
||||
.filter(pkg => pkg.isDirectory())
|
||||
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
|
||||
.map(pkg => `packages/${group.name}/${pkg.name}`),
|
||||
)
|
||||
type PublintResult =
|
||||
| { path: string; status: 'passed'; stdout: string; stderr: string }
|
||||
| { path: string; status: 'failed'; stdout: string; stderr: string; message: string }
|
||||
|
||||
for (const path of packages) {
|
||||
execFileSync('node_modules/.bin/publint', [path], { cwd: root, stdio: 'inherit' })
|
||||
function workspacePackages(): string[] {
|
||||
return readdirSync(packagesRoot, { withFileTypes: true })
|
||||
.filter(group => group.isDirectory())
|
||||
.flatMap(group =>
|
||||
readdirSync(resolve(packagesRoot, group.name), { withFileTypes: true })
|
||||
.filter(pkg => pkg.isDirectory())
|
||||
.filter(pkg => existsSync(resolve(packagesRoot, group.name, pkg.name, 'package.json')))
|
||||
.map(pkg => `packages/${group.name}/${pkg.name}`),
|
||||
)
|
||||
}
|
||||
|
||||
function publintConcurrency(total: number): number {
|
||||
if (total === 0) return 0
|
||||
|
||||
const raw = process.env[CONCURRENCY_ENV]
|
||||
if (raw !== undefined) {
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`publint-all: ${CONCURRENCY_ENV} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return Math.min(total, parsed)
|
||||
}
|
||||
|
||||
return Math.min(total, availableParallelism())
|
||||
}
|
||||
|
||||
function outputText(value: unknown): string {
|
||||
if (typeof value === 'string') return value
|
||||
if (Buffer.isBuffer(value)) return value.toString()
|
||||
return ''
|
||||
}
|
||||
|
||||
async function runPublint(path: string): Promise<PublintResult> {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync('node_modules/.bin/publint', [path], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
})
|
||||
return { path, status: 'passed', stdout, stderr }
|
||||
} catch (error: unknown) {
|
||||
const failed = error as { stdout?: unknown; stderr?: unknown; message?: string }
|
||||
return {
|
||||
path,
|
||||
status: 'failed',
|
||||
stdout: outputText(failed.stdout),
|
||||
stderr: outputText(failed.stderr),
|
||||
message: failed.message ?? 'publint failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runAll(paths: string[], concurrency: number): Promise<PublintResult[]> {
|
||||
let next = 0
|
||||
const results: Array<PublintResult | undefined> = []
|
||||
await Promise.all(Array.from({ length: concurrency }, async () => {
|
||||
for (;;) {
|
||||
const index = next
|
||||
next += 1
|
||||
const path = paths[index]
|
||||
if (path === undefined) return
|
||||
results[index] = await runPublint(path)
|
||||
}
|
||||
}))
|
||||
|
||||
return paths.map((path, index) => {
|
||||
const result = results[index]
|
||||
if (result === undefined) throw new Error(`publint-all: missing result for ${path}.`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function printResult(result: PublintResult): void {
|
||||
console.log(`Running publint for ${result.path}...`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.status === 'failed') console.error(result.message)
|
||||
}
|
||||
|
||||
const packages = workspacePackages()
|
||||
const concurrency = publintConcurrency(packages.length)
|
||||
console.log(`publint-all: linting ${packages.length} package(s) with ${concurrency} worker(s).`)
|
||||
|
||||
const results = await runAll(packages, concurrency)
|
||||
for (const result of results) printResult(result)
|
||||
|
||||
if (results.some(result => result.status === 'failed')) process.exit(1)
|
||||
|
||||
431
scripts/run-gates.ts
Normal file
431
scripts/run-gates.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Run local and CI quality gates with bounded in-process scheduling.
|
||||
*
|
||||
* The gate vocabulary stays in package.json; this runner only decides which
|
||||
* independent commands can overlap and which commands wait for built artifacts.
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import { readdir, rm } from 'node:fs/promises'
|
||||
import { availableParallelism } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
type Mode =
|
||||
| 'ci-primary'
|
||||
| 'ci-static'
|
||||
| 'ci-lint'
|
||||
| 'ci-coverage'
|
||||
| 'ci-snapshot'
|
||||
| 'ci-artifacts'
|
||||
| 'node-compat'
|
||||
| 'pre-push'
|
||||
type GateStatus = 'pending' | 'running' | 'passed' | 'failed' | 'skipped'
|
||||
|
||||
interface Gate {
|
||||
id: string
|
||||
label: string
|
||||
command: string
|
||||
args: string[]
|
||||
needs?: string[]
|
||||
env?: Record<string, string | undefined>
|
||||
input?: string
|
||||
verify?: (result: GateResult) => Promise<void>
|
||||
}
|
||||
|
||||
interface GateResult {
|
||||
gate: Gate
|
||||
status: GateStatus
|
||||
durationMs: number
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number | null
|
||||
error?: string
|
||||
}
|
||||
|
||||
interface RunningGate {
|
||||
gate: Gate
|
||||
promise: Promise<GateResult>
|
||||
}
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const mode = parseMode(process.argv[2])
|
||||
const gates = gatesForMode(mode)
|
||||
const maxConcurrency = concurrencyFromEnv('DSH_GATE_CONCURRENCY', defaultConcurrency(gates.length))
|
||||
const startedAt = performance.now()
|
||||
|
||||
console.log(`run-gates: ${mode} running ${gates.length} gate(s) with ${maxConcurrency} worker(s).`)
|
||||
|
||||
const results = await runGates(gates, maxConcurrency)
|
||||
printSummary(results, performance.now() - startedAt)
|
||||
|
||||
if (results.some(result => result.status === 'failed' || result.status === 'skipped')) process.exit(1)
|
||||
|
||||
function parseMode(raw: string | undefined): Mode {
|
||||
switch (raw) {
|
||||
case 'ci-primary':
|
||||
case 'ci-static':
|
||||
case 'ci-lint':
|
||||
case 'ci-coverage':
|
||||
case 'ci-snapshot':
|
||||
case 'ci-artifacts':
|
||||
case 'node-compat':
|
||||
case 'pre-push':
|
||||
return raw
|
||||
default:
|
||||
throw new Error(
|
||||
`run-gates: expected mode ci-primary | ci-static | ci-lint | ci-coverage | ci-snapshot | ci-artifacts | node-compat | pre-push, got ${JSON.stringify(raw)}.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function defaultConcurrency(total: number): number {
|
||||
return Math.min(total, Math.max(4, availableParallelism()))
|
||||
}
|
||||
|
||||
function concurrencyFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (raw === undefined || raw === '') return fallback
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1) {
|
||||
throw new Error(`run-gates: ${name} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
function pnpmScript(id: string, script: string, options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? script,
|
||||
command: pnpmBin(),
|
||||
args: ['run', script],
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmExec(id: string, args: string[], options: Partial<Gate> = {}): Gate {
|
||||
return {
|
||||
id,
|
||||
label: options.label ?? `pnpm exec ${args.join(' ')}`,
|
||||
command: pnpmBin(),
|
||||
args: ['exec', ...args],
|
||||
...options,
|
||||
}
|
||||
}
|
||||
|
||||
function pnpmBin(): string {
|
||||
return process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm'
|
||||
}
|
||||
|
||||
function nodeOptions(...options: string[]): string {
|
||||
return [process.env.NODE_OPTIONS, ...options].filter(option => option !== undefined && option !== '').join(' ')
|
||||
}
|
||||
|
||||
function gatesForMode(selected: Mode): Gate[] {
|
||||
switch (selected) {
|
||||
case 'ci-primary':
|
||||
return ciPrimaryGates()
|
||||
case 'ci-static':
|
||||
return ciStaticGates()
|
||||
case 'ci-lint':
|
||||
return [
|
||||
lintGate(),
|
||||
]
|
||||
case 'ci-coverage':
|
||||
return [
|
||||
coverageGate(),
|
||||
]
|
||||
case 'ci-snapshot':
|
||||
return [
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
]
|
||||
case 'ci-artifacts':
|
||||
return ciArtifactGates()
|
||||
case 'node-compat':
|
||||
return [
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
]
|
||||
case 'pre-push':
|
||||
return [
|
||||
pnpmScript('test', 'test'),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
pnpmScript('build', 'build'),
|
||||
...hygieneLeafGates({ artifactNeeds: ['build'] }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
function ciPrimaryGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('typecheck', 'typecheck'),
|
||||
lintGate(),
|
||||
coverageGate(),
|
||||
pnpmScript('snapshot', 'test:snapshot'),
|
||||
demoSmokeGate({ needs: ['lint'] }),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('build', 'build', { needs: ['typecheck'] }),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function ciStaticGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
demoSmokeGate(),
|
||||
...docSyncLeafGates(),
|
||||
pnpmScript('module-graph', 'verify-module-graph', { label: 'module graph' }),
|
||||
pnpmScript('knip', 'knip'),
|
||||
]
|
||||
}
|
||||
|
||||
function ciArtifactGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('build', 'build'),
|
||||
pnpmScript('publint', 'publint', { needs: ['build'] }),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
needs: ['build'],
|
||||
}),
|
||||
builtBinSmokeGate(),
|
||||
]
|
||||
}
|
||||
|
||||
function lintGate(): Gate {
|
||||
if (process.env.DSH_ESLINT_CACHE === '1') {
|
||||
return pnpmExec('lint', [
|
||||
'eslint',
|
||||
'.',
|
||||
'--cache',
|
||||
'--cache-location',
|
||||
'.cache/eslint/',
|
||||
'--cache-strategy',
|
||||
'content',
|
||||
], {
|
||||
label: 'lint',
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
return pnpmScript('lint', 'lint', {
|
||||
env: { NODE_OPTIONS: nodeOptions('--max-old-space-size=8192') },
|
||||
})
|
||||
}
|
||||
|
||||
function coverageGate(): Gate {
|
||||
return pnpmExec('coverage', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--coverage',
|
||||
...positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers'),
|
||||
], {
|
||||
label: 'test:coverage',
|
||||
})
|
||||
}
|
||||
|
||||
function positiveIntArg(envName: string, flag: string): string[] {
|
||||
const raw = process.env[envName]
|
||||
if (raw === undefined || raw === '') return []
|
||||
const parsed = Number.parseInt(raw, 10)
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 1 || String(parsed) !== raw) {
|
||||
throw new Error(`run-gates: ${envName} must be a positive integer, got ${JSON.stringify(raw)}.`)
|
||||
}
|
||||
return [`${flag}=${raw}`]
|
||||
}
|
||||
|
||||
function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
|
||||
const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds }
|
||||
return [
|
||||
pnpmScript('knip', 'knip'),
|
||||
pnpmScript('publint', 'publint', artifactOptions),
|
||||
pnpmScript('constraints', 'constraints'),
|
||||
pnpmScript('node-next-types', 'verify-node-next-types', {
|
||||
label: 'node-next types',
|
||||
...artifactOptions,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
function docSyncLeafGates(): Gate[] {
|
||||
return [
|
||||
pnpmScript('doc-typecheck', 'doc-typecheck'),
|
||||
pnpmScript('cordis-catalog', 'verify-cordis-catalog', { label: 'cordis catalog' }),
|
||||
pnpmScript('tool-catalog', 'verify-tool-catalog', { label: 'tool catalog' }),
|
||||
pnpmScript('persistence-catalog', 'verify-persistence-catalog', { label: 'persistence catalog' }),
|
||||
pnpmScript('doc-graphs', 'verify-doc-graphs', { label: 'doc graphs' }),
|
||||
pnpmScript('markdown-wrap', 'verify-md-wrap', { label: 'markdown wrap' }),
|
||||
pnpmScript('markdown-links', 'verify-md-links', { label: 'markdown links' }),
|
||||
pnpmScript('doc-refs', 'verify-doc-refs', { label: 'doc refs' }),
|
||||
pnpmScript('package-paths', 'verify-package-paths', { label: 'package paths' }),
|
||||
pnpmScript('mermaid', 'verify-mermaid'),
|
||||
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
|
||||
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
|
||||
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
|
||||
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
|
||||
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
|
||||
]
|
||||
}
|
||||
|
||||
function demoSmokeGate(options: { needs?: string[] } = {}): Gate {
|
||||
const dependencyOptions = options.needs === undefined ? {} : { needs: options.needs }
|
||||
return {
|
||||
id: 'demo-smoke',
|
||||
label: 'demo smoke',
|
||||
command: pnpmBin(),
|
||||
args: ['run', 'demo:echo'],
|
||||
input: 'echo ci smoke\n',
|
||||
...dependencyOptions,
|
||||
verify: async (result) => {
|
||||
const output = result.stdout + result.stderr
|
||||
if (!output.includes('[tool call] echo({"text":"ci smoke"})')) {
|
||||
throw new Error('demo smoke did not show the echo tool call.')
|
||||
}
|
||||
if (!output.includes('[tool result] ECHO: CI SMOKE')) {
|
||||
throw new Error('demo smoke did not show the echo tool result.')
|
||||
}
|
||||
const sessionDir = join(root, '.sessions', '_no-cwd')
|
||||
const entries = await readdir(sessionDir)
|
||||
if (!entries.some(entry => /^main-session-.+\.jsonl$/.test(entry))) {
|
||||
throw new Error('demo smoke did not create a main-session JSONL log.')
|
||||
}
|
||||
await rm(join(root, '.sessions'), { recursive: true, force: true })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function builtBinSmokeGate(): Gate {
|
||||
return pnpmExec('built-bin-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'--config',
|
||||
'vitest.e2e.config.ts',
|
||||
'packages/ui/stdio-agent/tests/built-bin.e2e.ts',
|
||||
'packages/ui/acp-agent/tests/built-bin.e2e.ts',
|
||||
], {
|
||||
label: 'built-bin smoke',
|
||||
needs: ['build'],
|
||||
})
|
||||
}
|
||||
|
||||
async function runGates(allGates: Gate[], maxActive: number): Promise<GateResult[]> {
|
||||
const states = new Map<string, GateStatus>(allGates.map(gate => [gate.id, 'pending']))
|
||||
const results = new Map<string, GateResult>()
|
||||
const running: RunningGate[] = []
|
||||
|
||||
for (;;) {
|
||||
let madeProgress = false
|
||||
while (running.length < maxActive) {
|
||||
const ready = allGates.find(gate => states.get(gate.id) === 'pending' && dependenciesPassed(gate, states))
|
||||
if (ready === undefined) break
|
||||
states.set(ready.id, 'running')
|
||||
running.push({ gate: ready, promise: runGate(ready) })
|
||||
console.log(`run-gates: start ${ready.label}`)
|
||||
madeProgress = true
|
||||
}
|
||||
|
||||
if (running.length === 0) {
|
||||
const pending = allGates.filter(gate => states.get(gate.id) === 'pending')
|
||||
for (const gate of pending) {
|
||||
const failedDeps = (gate.needs ?? []).filter(id => states.get(id) !== 'passed')
|
||||
const result: GateResult = {
|
||||
gate,
|
||||
status: 'skipped',
|
||||
durationMs: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
exitCode: null,
|
||||
error: `dependency failed or skipped: ${failedDeps.join(', ')}`,
|
||||
}
|
||||
states.set(gate.id, 'skipped')
|
||||
results.set(gate.id, result)
|
||||
printResult(result)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!madeProgress) {
|
||||
const settled = await Promise.race(running.map(async item => ({ item, result: await item.promise })))
|
||||
running.splice(running.indexOf(settled.item), 1)
|
||||
states.set(settled.item.gate.id, settled.result.status)
|
||||
results.set(settled.item.gate.id, settled.result)
|
||||
printResult(settled.result)
|
||||
}
|
||||
}
|
||||
|
||||
return allGates.map((gate) => {
|
||||
const result = results.get(gate.id)
|
||||
if (result === undefined) throw new Error(`run-gates: missing result for ${gate.id}.`)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
function dependenciesPassed(gate: Gate, states: Map<string, GateStatus>): boolean {
|
||||
return (gate.needs ?? []).every(id => states.get(id) === 'passed')
|
||||
}
|
||||
|
||||
async function runGate(gate: Gate): Promise<GateResult> {
|
||||
const started = performance.now()
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
const exitCode = await new Promise<number | null>((resolveExit, reject) => {
|
||||
const child = spawn(gate.command, gate.args, {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...gate.env },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
})
|
||||
child.stdout.setEncoding('utf8')
|
||||
child.stderr.setEncoding('utf8')
|
||||
child.stdout.on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.on('data', (chunk: string) => { stderr += chunk })
|
||||
child.on('error', reject)
|
||||
child.on('close', resolveExit)
|
||||
if (gate.input !== undefined) child.stdin.end(gate.input)
|
||||
else child.stdin.end()
|
||||
})
|
||||
|
||||
let status: GateStatus = exitCode === 0 ? 'passed' : 'failed'
|
||||
let error: string | undefined
|
||||
if (status === 'passed' && gate.verify !== undefined) {
|
||||
try {
|
||||
await gate.verify({ gate, status, durationMs: performance.now() - started, stdout, stderr, exitCode })
|
||||
} catch (verifyError: unknown) {
|
||||
status = 'failed'
|
||||
error = verifyError instanceof Error ? verifyError.message : String(verifyError)
|
||||
}
|
||||
}
|
||||
|
||||
const result: GateResult = {
|
||||
gate,
|
||||
status,
|
||||
durationMs: performance.now() - started,
|
||||
stdout,
|
||||
stderr,
|
||||
exitCode,
|
||||
}
|
||||
if (error !== undefined) result.error = error
|
||||
return result
|
||||
}
|
||||
|
||||
function printResult(result: GateResult): void {
|
||||
const seconds = (result.durationMs / 1000).toFixed(2)
|
||||
console.log(`\n== ${result.status.toUpperCase()} ${result.gate.label} (${seconds}s) ==`)
|
||||
process.stdout.write(result.stdout)
|
||||
process.stderr.write(result.stderr)
|
||||
if (result.error !== undefined) console.error(result.error)
|
||||
}
|
||||
|
||||
function printSummary(results: GateResult[], durationMs: number): void {
|
||||
const passed = results.filter(result => result.status === 'passed').length
|
||||
const failed = results.filter(result => result.status === 'failed').length
|
||||
const skipped = results.filter(result => result.status === 'skipped').length
|
||||
const seconds = (durationMs / 1000).toFixed(2)
|
||||
console.log(`\nrun-gates: ${passed} passed, ${failed} failed, ${skipped} skipped in ${seconds}s.`)
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export default defineConfig({
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
reporter: ['text', 'html'],
|
||||
reporter: process.env.CI ? ['text'] : ['text', 'html'],
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,8 +7,9 @@ import { defineConfig } from 'vitest/config'
|
||||
//
|
||||
// Secrets: tests gate themselves with
|
||||
// `describe.skipIf(!process.env.DEEPSEEK_API_KEY)`, so the suite passes
|
||||
// (all-skipped) without credentials — CI has none and stays green. Put the
|
||||
// key in the environment or in a gitignored `.env` at the repo root:
|
||||
// (all-skipped) without credentials. The keyless CI workflow relies on that;
|
||||
// the real-API workflow preflights the secret and fails loudly if it is absent.
|
||||
// Put the key in the environment or in a gitignored `.env` at the repo root:
|
||||
//
|
||||
// DEEPSEEK_API_KEY=sk-…
|
||||
// DEEPSEEK_BASE_URL=https://… # optional, defaults to the public API
|
||||
@@ -19,6 +20,21 @@ try {
|
||||
// No .env — fine, the environment may already carry the variables.
|
||||
}
|
||||
|
||||
const DEFAULT_E2E_MAX_WORKERS = 4
|
||||
|
||||
function positiveIntFromEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
if (raw === undefined || raw === '') return fallback
|
||||
|
||||
const value = Number(raw)
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer, got ${JSON.stringify(raw)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
const e2eMaxWorkers = positiveIntFromEnv('DSH_E2E_MAX_WORKERS', DEFAULT_E2E_MAX_WORKERS)
|
||||
|
||||
export default defineConfig({
|
||||
// Same resolution note as vitest.config.ts: bare workspace names resolve
|
||||
// through the root tsconfig paths map; the native option cannot do this.
|
||||
@@ -31,9 +47,10 @@ export default defineConfig({
|
||||
testTimeout: 120_000,
|
||||
hookTimeout: 30_000,
|
||||
retry: 2,
|
||||
// Run e2e files one at a time: the shared internal API key has a small
|
||||
// concurrency quota, and parallel files issue enough simultaneous requests
|
||||
// to trip it (manifesting as flaky rate-limit errors).
|
||||
fileParallelism: false,
|
||||
// Run files in a bounded pool: enough lower-level parallelism to keep CI
|
||||
// and local with-key runs moving, while leaving a resource knob for shared
|
||||
// API quotas (`DSH_E2E_MAX_WORKERS=1` restores serial execution).
|
||||
fileParallelism: e2eMaxWorkers > 1,
|
||||
maxWorkers: e2eMaxWorkers,
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user