chore: merge release/v3.8.49 into fix/port-pr-2402-windows-build-isolation to refresh CI verdict

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-15 11:26:32 -03:00
201 changed files with 14761 additions and 656 deletions

View File

@@ -505,7 +505,9 @@ NEXT_PUBLIC_CLOUD_URL=
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
# OpenCode Go has no public quota API — this has no default and stays
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings

9
.env.homolog.example Normal file
View File

@@ -0,0 +1,9 @@
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
HOMOLOG_BASE_URL=http://192.168.0.15:20128
# Senha de management do dashboard da VPS (a mesma do /login)
HOMOLOG_ADMIN_PASSWORD=
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
# Só preencha para depurar uma camada isolada com uma key fixa.
HOMOLOG_API_KEY=
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter

View File

@@ -33,6 +33,7 @@ jobs:
docs: ${{ steps.classify.outputs.docs }}
i18n: ${{ steps.classify.outputs.i18n }}
workflow: ${{ steps.classify.outputs.workflow }}
testsOnly: ${{ steps.classify.outputs.testsOnly }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with:
@@ -124,11 +125,23 @@ jobs:
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:tracked-artifacts
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
- name: hadolint (Dockerfile)
run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile
- run: npm run check:lockfile
- run: npm run check:licenses
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
- run: npm run typecheck:core
# #7033: typecheck:core's curated file allowlist does not cover
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
# true, so `next build` never type-checks it either) — orphaned
# identifiers there (see #6625/#6909) were invisible to CI. This gate
# runs tsc scoped to the dashboard tree against a frozen baseline of
# pre-existing errors; only NEW errors fail it.
- run: npm run check:dashboard-typecheck
# typecheck:noimplicit:core dropped from this job (2026-07 optimize):
# it was advisory (continue-on-error) and largely subsumed by the blocking
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
@@ -148,7 +161,7 @@ jobs:
# The coverage.* metrics degrade gracefully: the download is continue-on-error and
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
permissions:
@@ -258,7 +271,7 @@ jobs:
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
# Path filter: code-only (scanners/ratchets target production surface).
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }}
steps:
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
@@ -609,12 +622,26 @@ jobs:
- name: Assert dist/server.js exists
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
- run: npm run check:pack-artifact
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
- name: Boot-smoke the packed tarball
run: npm run check:pack-boot
electron-package-smoke:
name: Electron Package Smoke
runs-on: ubuntu-latest
timeout-minutes: 25
name: Electron Package Smoke (${{ matrix.os }})
runs-on: ${{ matrix.os }}
timeout-minutes: 30
needs: build
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (the ABI rebuild + spawn plan) per release
# PR; ubuntu keeps the full pack + headless smoke.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
CSC_IDENTITY_AUTO_DISCOVERY: "false"
@@ -640,9 +667,15 @@ jobs:
working-directory: electron
run: npm install --no-audit --no-fund
- name: Pack Electron app
if: runner.os == 'Linux'
working-directory: electron
run: npm run pack
- name: Prepare Electron standalone (Windows ABI rebuild + spawn path)
if: runner.os == 'Windows'
working-directory: electron
run: npm run prepare:bundle
- name: Smoke packaged Electron app
if: runner.os == 'Linux'
env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000
run: xvfb-run -a npm run electron:smoke:packaged
@@ -725,12 +758,23 @@ jobs:
- uses: ./.github/actions/npm-ci-retry
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
- run: npm run test:vitest
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
# fixes the components/tests; then drop continue-on-error to make it blocking.
- run: npm run test:vitest:ui
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:testvitest migration,
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
# Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
@@ -741,7 +785,7 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' }}
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
@@ -789,6 +833,7 @@ jobs:
--merge-async \
--reporter=text-summary \
--reporter=json-summary \
--reporter=lcov \
--exclude=tests/** \
--exclude=**/*.test.* \
--check-coverage \
@@ -810,6 +855,18 @@ jobs:
> coverage/coverage-report.md
fi
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
# WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov —
# informational during calibration (codecov.yml sets informational: true);
# promote to blocking only after ~2 weeks without false blocks. The lcov
# reporter above also fixes coverage/lcov.info being silently absent
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
uses: codecov/codecov-action@04b047e8bb82a0c002c8312c1c880fbc6a999d45 # v5
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v7
@@ -960,7 +1017,12 @@ jobs:
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
timeout-minutes: 45
needs: build
needs: [build, changes]
# WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min).
# It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md:
# production-broken only, full-suite evidence from the previous green run linked in the
# PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app).
if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
strategy:
fail-fast: false
matrix:
@@ -995,7 +1057,33 @@ jobs:
- name: Extract Next.js build artifact
run: |
tar -xzf /tmp/e2e-build.tar.gz
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
# critical path. The balancer self-verifies completeness and exits non-zero on
# any inconsistency, falling back to plain --shard (never fewer specs).
- name: Run E2E tests (duration-balanced shard)
env:
SHARD: ${{ matrix.shard }}
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
run: |
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
# shellcheck disable=SC2086 — FILES is our own newline-separated path list
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
else
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
fi
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: junit-e2e-results.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
test-integration:
name: Integration Tests (${{ matrix.shard }}/2)

View File

@@ -10,7 +10,10 @@ jobs:
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
continue-on-error: true
timeout-minutes: 12
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
# mid-run) — 25min leaves real headroom for the actual DAST steps.
timeout-minutes: 25
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa

View File

@@ -1,11 +1,18 @@
name: Nightly Release-Green
name: Release-Green (continuous)
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
#
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This
# nightly reproduces the release-equivalent validation on the active release branch
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
# workflow reproduces the release-equivalent validation on the release branch and,
# when there are HARD failures, opens/updates a single tracking issue.
#
# WS5.1 (v3.8.49 quality plan) — two modes:
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
# COMBINATION right after every PR merge, attributing the offending push range in
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
#
# It is NOT a required status check and never touches a contributor PR — it only
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
@@ -14,8 +21,23 @@ name: Nightly Release-Green
# package-artifact) flip the issue open.
on:
push:
branches: ["release/v*"]
paths:
- "src/**"
- "open-sse/**"
- "bin/**"
- "electron/**"
- "scripts/**"
- "tests/**"
- "config/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
schedule:
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
- cron: "23 18 * * *" # full sweep — evening
workflow_dispatch:
inputs:
branch:
@@ -28,7 +50,9 @@ permissions:
issues: write
concurrency:
group: nightly-release-green
# push storms during merge campaigns collapse to the newest commit per branch;
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
env:
@@ -56,10 +80,15 @@ jobs:
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
EVENT_NAME: ${{ github.event_name }}
PUSHED_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
elif [ "$EVENT_NAME" = "push" ]; then
# validate exactly what was pushed, not the highest branch
TARGET="$PUSHED_REF"
else
# highest release/vX.Y.Z by semver among remote branches
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
@@ -93,16 +122,26 @@ jobs:
- name: Release-green validation (full)
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
# operator env; hosted ignores the unknown flag before #6300 lands).
# --full-ci: ALSO run every static gate from ci.yml's gate jobs (lint,
# quality-gate, quality-extended, docs-sync-strict, pr-test-policy). PRs into
# release/** only get the fast-gates, so these accrue silently and explode in
# layers on the release PR (v3.8.46: 11 static base-reds leaked). Running them
# nightly opens the tracking issue the moment one lands, not at release time.
node scripts/quality/validate-release-green.mjs --json --with-build --hermetic --full-ci \
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
# pr-test-policy) + build + full suites. PRs into release/** only get the
# fast-gates, so these accrue silently and explode in layers on the release PR
# (v3.8.46: 11 static base-reds leaked).
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
@@ -114,15 +153,28 @@ jobs:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
TITLE="🔴 Release branch not green: ${TARGET}"
{
echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo ""
echo "**Run:** ${RUN_URL}"
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log

View File

@@ -22,6 +22,14 @@ on:
- latest
- next
- historic
publish_mode:
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
required: false
default: "staged"
type: choice
options:
- staged
- direct
workflow_call:
inputs:
version:
@@ -166,8 +174,34 @@ jobs:
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
- name: Publish to npm
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
# real tarball and fails the publish before anything reaches the registry.
- name: Boot-smoke the tarball before ANY publish
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-boot
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
# parks the exact bytes on the registry WITHOUT making them installable; the
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
# fallback (legacy immediate publish) via workflow_dispatch.
- name: Ensure npm supports staged publishing
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in the publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
- name: Publish to npm (staged — owner approves with 2FA)
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
@@ -175,10 +209,32 @@ jobs:
run: |
set -euo pipefail
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, `npm publish --tag historic` will
# NOT promote it to `@latest`.
# accidentally an older release, the historic tag will NOT claim `@latest`.
npm stage publish --provenance --access public --tag "$TAG"
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
echo "The exact bytes are parked on the registry. To release them:"
echo '```'
echo "npm stage list omniroute # find the stage id"
echo "npm stage approve <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$GITHUB_STEP_SUMMARY"
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'

View File

@@ -62,7 +62,7 @@ jobs:
docs-gates:
name: Docs Gates (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -82,7 +82,7 @@ jobs:
name: Fast Quality Gates
needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
@@ -143,6 +143,24 @@ jobs:
- run: npm run check:complexity-ratchets
- name: Typecheck (core)
run: npm run typecheck:core
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
- name: Typecheck (dashboard)
run: npm run check:dashboard-typecheck
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
# Promote to the blocking gate after ~1 week of parity with the step above.
- name: Typecheck (core) — TS7 native shadow (advisory)
continue-on-error: true
run: |
RC=0
START=$(date +%s)
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
exit $RC
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
# selector returns __RUN_ALL__ — full-suite authority is the parallel
@@ -196,7 +214,7 @@ jobs:
fast-vitest:
name: Vitest (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
env:
@@ -212,12 +230,23 @@ jobs:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run test:vitest
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
@@ -263,7 +292,7 @@ jobs:
lint-guard:
name: No new ESLint warnings
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
steps:
@@ -302,7 +331,7 @@ jobs:
merge-integrity:
name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
env:

7
.gitignore vendored
View File

@@ -72,6 +72,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.homolog.example
# Provider API keys (never commit)
*.api-key
.nvidia-api-key
@@ -242,3 +243,9 @@ _artifacts/
# CI/local quality artifacts (eslint-results.json, etc.)
.artifacts/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/

View File

@@ -74,3 +74,16 @@
# '''tests/unit/''',
# ]
#
[[rules]]
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
# da Anthropic (documentado publicamente, não é segredo).
id = "generic-api-key"
[rules.allowlist]
description = "Field names + public Anthropic beta-header value (não são segredos)"
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
]

55
.mergify.yml Normal file
View File

@@ -0,0 +1,55 @@
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
#
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
# identity. The manual merge-train validated batches by hand; this queue automates
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
#
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
# pre-merge ⭐ gate):
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
# IS the merge approval; Mergify only executes it.
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
# targeting the frozen branch — the freeze is a human-honored coordination signal
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
# • Never label a PR another session is actively working (Hard Rule #22b).
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
# rejected (no wildcard support on personal-account repos).
queue_conditions:
- base~=^release/v\d+\.\d+\.\d+$
- label=queue
- -draft
- -conflict
# "Everything that ran is green, nothing still running, AND the always-on
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
- "#check-failure=0"
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
# don't hold a lone PR hostage waiting for siblings.
batch_size: 10
batch_max_wait_time: 5 min
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash
pull_request_rules:
- name: clean up the queue label after merge
conditions:
- merged
actions:
label:
remove:
- queue

View File

@@ -0,0 +1 @@
- **Homologation suite**: new `npm run homolog` runs the full release-homologation battery against the deployed VPS — health/version parity, API + real SSE streaming with an ephemeral API key (created and revoked by the run), minimal-cost real-provider smoke (promptfoo generated from the live catalog), and a Playwright sweep that loads every dashboard route and exercises the API-key UI flow — emitting a unified CTRF report that backs the release STOP #2 checklist

View File

@@ -0,0 +1 @@
- fix(providers): preserve relayAuth for vercel/deno/cloudflare relay proxies referenced by-id from the no-auth-provider Proxy Pool dropdown (#5716)

View File

@@ -0,0 +1 @@
- fix(sse): sanitize non-Latin1 characters before embedding combo diagnostics in HTTP headers, preventing a ByteString crash on quality-check failure (#6612)

View File

@@ -0,0 +1 @@
- fix(providers): reject chat-completions requests for cloud-agent-only providers like jules instead of silently mis-routing them to OpenAI's endpoint (#6699)

View File

@@ -0,0 +1 @@
- **fix(db):** cap the sql.js OOM-during-probe path in `getDbInstance()` at 3 attempts with a terminal diagnostic — previously only the generic-corruption probe-failure path had a cycle-breaker (#6632), so a persistently OOMing `storage.sqlite` probe re-threw the identical error forever on every call from every background poller, hanging the app with "Internal Server Error" and no self-recovery (#6835).

View File

@@ -0,0 +1 @@
- fix(providers): DuckDuckGo AI Chat executor propagates the real upstream status (429 rate limit with `Retry-After`) instead of misclassifying VQD-token acquisition failures as a hardcoded 503 (#6996)

View File

@@ -0,0 +1 @@
- fix(providers): refresh OpenCode (`oc`) free-tier model catalog — 6 delisted IDs replaced with the 4 currently-live free models (#6998)

View File

@@ -0,0 +1 @@
- fix(api): raise the main server's `keepAliveTimeout`/`headersTimeout` well above Node's 5s default so pooled keep-alive clients (e.g. JetBrains AI Assistant's JVM `HttpClient`) stop getting 0 bytes back on a reused connection (#7003)

View File

@@ -0,0 +1 @@
- fix(compression): wire the adaptive context-budget "dial" (`contextBudget`) into the settings schema and DB so it can actually be persisted via `PUT /api/settings/compression`, instead of being silently rejected (#7005)

View File

@@ -0,0 +1 @@
- fix(usage): stop opencode-go quota lookup from defaulting to an unrelated Z.AI endpoint (#7022)

View File

@@ -0,0 +1 @@
- fix(ci): add a dashboard-scoped typecheck gate covering `src/app/(dashboard)` TSX, previously invisible to `typecheck:core` and `next build` (#7033)

View File

@@ -0,0 +1 @@
- fix(build): extend the Turbopack `ignoreIssue` suppression to `open-sse/services/compression/**`, matching the `getModuleDir()` dynamic-path fs pattern already suppressed for `src/lib/agentSkills/**` in #6582, eliminating the remaining 610 "Overly broad patterns" warnings (#7051)

View File

@@ -0,0 +1 @@
- fix(providers): web-cookie connection-test/cookie-validation probe (zai-web and every other registry-entry web-cookie provider) now honors the configured HTTP/SOCKS proxy — the `/models` probe routed through `directHttpsRequest`'s hardcoded native-fetch bypass, silently skipping proxy resolution even though the executor's actual chat traffic already respected it (#7058)

View File

@@ -0,0 +1 @@
- fix(resilience): recognize Ollama Cloud's 5-hour session usage-limit 429 as quota-exhausted instead of a generic rate limit (#7071)

View File

@@ -0,0 +1 @@
- fix(dashboard): restore mobile single-column fallback on the Provider Quota page card grid, fixing clipped labels/buttons on phone-width viewports (#7072)

View File

@@ -0,0 +1 @@
- fix(dashboard): include proxyId when testing a saved registry proxy so SOCKS5/auth credentials are loaded (#7080)

View File

@@ -0,0 +1 @@
- **fix(sse):** claude-web now surfaces the real upstream error body for non-SSE 400/403/429/500 responses instead of reporting "no response body" — the streaming client was discarding the already-captured temp-file bytes and reading the native binding's empty in-memory body field instead (#7134).

View File

@@ -0,0 +1 @@
- fix(db): honor combo-level proxy assignments from the registry when resolving a connection's proxy (#7149)

View File

@@ -0,0 +1 @@
- fix(dashboard): wire modelAliases fetch into HermesAgentToolCard so OpenRouter and other passthrough providers appear in the Hermes Agent role picker (#7151)

View File

@@ -0,0 +1 @@
- fix(dashboard): filter hidden custom models out of the legacy combo model picker (#7156)

View File

@@ -0,0 +1 @@
- fix(dashboard): Agent Bridge DNS toggle now sends POST (was PUT), fixing HTTP 405 on Start/Stop DNS (#7157)

View File

@@ -0,0 +1 @@
- fix(dashboard): implement missing `handleToggleSource` callback on the Free Pool tab so the page no longer crashes with a `ReferenceError` (#7161)

View File

@@ -0,0 +1 @@
- fix(sse): stop duplicating text in Gemini Web streamed responses (#7163)

View File

@@ -0,0 +1 @@
- **Packaging**: new `check:pack-boot` gate packs the real npm tarball, installs it into a clean prefix (postinstall runs for real) and boots the installed CLI until `/api/monitoring/health` returns 200 with the packed version — the runtime gate that structure checks could not provide (3 releases shipped boot-crashing tarballs with green packaging lists: tls-options/3.8.41, #7040, #7065). Wired into the CI `package-artifact` job and `check:release-green --with-build`

View File

@@ -0,0 +1 @@
- **Packaging**: pack-artifact closure tests now cover EVERY npm-shipped dist wrapper (derived from `EXTRA_MODULE_ENTRIES`) and the `bin/omniroute.mjs` CLI entry — including dynamic `import()` and `require()` forms the original server-ws test missed — requiring each local import in both the prepublish prune allowlist and `check:pack-artifact`; `bin/cli/data-dir.mjs` and `bin/cli/utils/storageKeyProvision.mjs` are now required tarball paths (#7065 class hardening, WS1.1)

View File

@@ -0,0 +1 @@
- **Providers**: yuanbao-web no longer forwards a foreign single cookie pair upstream — `buildYuanbaoCookie` only trusts `hy_user`/`hy_token` extractions the input explicitly names, so a missing session token now fails fast with the local 401 guidance instead of a live Tencent round-trip

View File

@@ -0,0 +1 @@
- **CI**: Codecov patch-coverage on every PR diff (informational during calibration — `codecov.yml` sets nothing blocking; strict-patch/lenient-project philosophy on top of the existing 60% c8 floor + ratchet); the CI coverage job now actually emits `coverage/lcov.info` (the `lcov` reporter was missing, so the artifact silently skipped it — the same file Sonar consumes)

View File

@@ -0,0 +1 @@
- **CI**: raise the dast-smoke job timeout 12→25min — the CLI bundle build alone varies 6-11min on GitHub-hosted runners, so the old cap killed Schemathesis mid-run (3 consecutive false-negative timeouts on 2026-07-14)

View File

@@ -0,0 +1 @@
- **CI**: E2E matrix shards are now duration-balanced (LPT bin-packing over `config/quality/e2e-timings.json`) instead of Playwright's count-based `--shard` — measured skew was 14× (24m47s vs 1m47s), making E2E the CI critical path; the balancer self-verifies that every spec lands in exactly one shard and falls back to plain `--shard` on any inconsistency

View File

@@ -0,0 +1 @@
- **CI**: the Electron Package Smoke job now runs a Windows leg that executes `prepare:bundle` (the native ABI rebuild + spawn plan) per release PR — the v3.8.48 Windows bug (`npx.cmd` spawned without shell) could previously only surface on the release tag, its first-ever execution

View File

@@ -0,0 +1 @@
- **Quality gates hygiene (WS6/D3 + WS1.7)**: gitleaks baseline zeroed — the 3 frozen `generic-api-key` false positives (latency field names + the public Anthropic beta-header value) are allowlisted with justification, so any NEW secret finding now regresses the ratchet from 0; the orphaned `semgrepFindings` baseline metric was dropped (never wired to a gate; CodeQL covers the OWASP families); Dockerfile now has a hadolint gate in the lint job (digest-pinned, error-threshold — the 5 pre-existing warnings stay visible without blocking)

View File

@@ -0,0 +1 @@
- **CI**: hotfix fast-lane — PRs labeled `hotfix` (owner-applied, production-broken only; entry policy in `docs/ops/RELEASE_CHECKLIST.md`) skip the 9-shard E2E matrix, coverage ratchet and extended gates while keeping build, unit/integration/vitest, lint/typecheck and the tarball boot-smoke (~15min instead of ~33min); tests-only diffs outside `tests/e2e/` skip the E2E matrix automatically via the new `testsOnly` change-classification output

View File

@@ -0,0 +1 @@
- **CI**: quality.yml draft guards now also match Mergify speculative merge-queue PRs (`mergify/merge-queue/*` heads are drafts by design) — without this every queued batch failed its anchor check in 2s and dequeued

View File

@@ -0,0 +1 @@
- **Merge queue (D5)**: reviewed PRs now merge through the Mergify queue (`.mergify.yml`, Open Source plan) — entry is the owner-applied `queue` label AFTER the pre-merge ⭐ gate; batches of up to 10 validate together with automatic bisection of red batches (~log2(N) instead of N revalidations); the manual merge-train is codified as the fallback runbook in `docs/ops/MERGE_TRAIN.md` with the freeze/cross-session guardrails

View File

@@ -0,0 +1 @@
- **Release**: npm publishing is now STAGED by default — the workflow boots the packed tarball (`check:pack-boot`) and runs `npm stage publish` (bytes parked on the registry, not installable); the owner verifies the staged bytes and releases them with `npm stage approve` + 2FA, moving the human gate to after the proof (the structural fix for the #7065 broken-tarball class); `publish_mode=direct` remains as a documented emergency fallback

View File

@@ -0,0 +1 @@
- **Docs**: `QUALITY_GATES.md` now codifies the per-runner test retry policy (Playwright 1 CI retry with trace; Vitest per-test explicit quarantine only; node:test never) with target flake SLOs, and the release-level ratchet-drift rule (combination drift on the pure tip is the release captain's to fix once on the branch — never pushed onto contributor PRs, never rebaselined per-PR)

View File

@@ -0,0 +1 @@
- **CI**: release-green validation is now continuous — every code push to `release/v*` (including the captain's direct sync-back pushes, the one previously ungated write path) triggers a `--quick` HARD-gate run with per-branch superseded-run cancellation, and the tracking issue names the offending push range; the deep `--with-build --full-ci` sweep now runs 3×/day instead of nightly-only (base-red MTTD: ≤24h → ~15min after the offending push)

View File

@@ -0,0 +1 @@
- **Ops**: `scripts/ops/runner-janitor.sh` + `docs/ops/RUNNER_BOX.md` codify the self-hosted runner box hygiene that was manual discipline — 30min cron sweeping stale runner temp dirs, alerting at ≥85% disk, and enforcing the proven 4-runner ceiling on the 16 GB box (8-wide OOM-killed jobs twice on the v3.8.47 release day)

View File

@@ -0,0 +1 @@
- **Tests**: the #6634 self-reference test now fetches `origin/main` on demand and skips cleanly when the ref is unreachable — it failed as a false positive on shallow/single-ref checkouts (GitHub-hosted runners)

View File

@@ -0,0 +1 @@
- **Release tooling**: `sync-next-cycle.mjs` now runs `validate-release-green --quick` on the merged tree BEFORE pushing the parallel-cycle sync-back — the one write path to the release branch that had no CI gate; a red merged tree stays local instead of turning the whole PR queue red (`--skip-green-gate` is the documented emergency hatch)

View File

@@ -0,0 +1 @@
- **CI**: Playwright E2E and both vitest suites now emit JUnit and upload to Trunk Flaky Tests (org `omniroute`) — advisory step, own-origin only, uploader action SHA-pinned (WS5.2/5.3 of the quality plan; node:test stays out of the first wave)

View File

@@ -0,0 +1 @@
- **CI**: the fast-path Vitest job (every PR) now also emits JUnit and uploads to Trunk Flaky Tests — the heavy-gate uploads alone (release PR only) would never accumulate flaky-detection volume

View File

@@ -0,0 +1 @@
- **CI**: TypeScript 7 (native compiler, GA 2026-07-08) now runs as an advisory SHADOW of the blocking `typecheck:core` gate on the fast path — same tsconfig, isolated `npx` (no dependency change; the Compiler API only lands in TS 7.1, so typescript-eslint/type-coverage/Stryker stay on 6.x). Local parity proven (0 errors on both, exit 0); promotion to the blocking gate after ~1 week of CI parity

View File

@@ -0,0 +1 @@
- **Release tooling**: new `scripts/release/verify-published.mjs <version>` — post-publish net that installs the published version from the public registry inside a clean `node:24-slim` container and boots it until `/api/monitoring/health` reports the expected version (validates the exact bytes users install, on a machine with no repo/devbox state); wired into the release Phase 4 monitoring playbook

View File

@@ -0,0 +1 @@
- **CI**: promote `test:vitest:ui` to a blocking gate — the suite is 870/870 green again after the WS6.1 triage (#7127), so `continue-on-error` is removed from the vitest job

View File

@@ -0,0 +1 @@
- chore(tests): fix all 70 failing `test:vitest:ui` tests across 30 files (was advisory/parked) — root causes were 15 node:test-authored `.tsx` files never collected by vitest, a missing `window.matchMedia` jsdom polyfill, stale assertions against a redesigned BuildTab wizard / CompressionHub Phase-2 UI, and one obsolete test for a retired Plans screen; suite is now 158/158 files, 870/870 tests green (promotion to blocking is a follow-up)

19
codecov.yml Normal file
View File

@@ -0,0 +1,19 @@
# Codecov — WS5.6/D7 of the v3.8.49 quality/velocity plan.
# Philosophy: strict patch, lenient project — the project floor/ratchet already
# lives in quality-baseline.json + the c8 60% gate; Codecov adds the DIFF view
# ("new lines in this PR are covered"), which the global ratchet cannot see.
# INFORMATIONAL during calibration: nothing here blocks a PR. Promote by flipping
# informational to false after ~2 weeks without false blocks (owner decision).
coverage:
status:
project:
default:
informational: true
patch:
default:
target: 70%
informational: true
comment:
layout: "condensed_header, diff"
behavior: default
require_changes: true

View File

@@ -0,0 +1,259 @@
{
"open-sse/services/payloadRules.ts": {
"TS2677": 1
},
"src/app/(dashboard)/dashboard/HomePageClient.tsx": {
"TS2339": 16
},
"src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient.tsx": {
"TS2503": 3
},
"src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx": {
"TS2503": 2
},
"src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx": {
"TS2503": 2
},
"src/app/(dashboard)/dashboard/cache/__tests__/CachePage.test.tsx": {
"TS2305": 3,
"TS1117": 1
},
"src/app/(dashboard)/dashboard/cache/__tests__/CachePerformance.test.tsx": {
"TS2305": 1,
"TS2322": 2
},
"src/app/(dashboard)/dashboard/cache/__tests__/CacheTrends.test.tsx": {
"TS2305": 1,
"TS2322": 6
},
"src/app/(dashboard)/dashboard/cache/__tests__/IdempotencyLayer.test.tsx": {
"TS2305": 1
},
"src/app/(dashboard)/dashboard/cache/__tests__/MemoryCards.test.tsx": {
"TS2305": 1,
"TS2322": 1
},
"src/app/(dashboard)/dashboard/cache/components/CachePerformance.tsx": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/cli-code/components/ClaudeToolCard.tsx": {
"TS2339": 2
},
"src/app/(dashboard)/dashboard/cli-code/components/CodexToolCard.tsx": {
"TS2345": 3
},
"src/app/(dashboard)/dashboard/cli-code/components/CustomCliCard.tsx": {
"TS2345": 1
},
"src/app/(dashboard)/dashboard/cli-code/components/DroidToolCard.tsx": {
"TS2554": 2
},
"src/app/(dashboard)/dashboard/combos/page.tsx": {
"TS2339": 4,
"TS2345": 5,
"TS2698": 1,
"TS2322": 13
},
"src/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/costs/CostOverviewTab.tsx": {
"TS2304": 1
},
"src/app/(dashboard)/dashboard/costs/quota-share/QuotaSharePageClient.tsx": {
"TS2551": 7,
"TS2322": 2,
"TS2719": 2,
"TS2739": 1
},
"src/app/(dashboard)/dashboard/costs/quota-share/components/StackedAllocationBar.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/costs/quota-share/components/UsageLogCard.tsx": {
"TS2869": 2
},
"src/app/(dashboard)/dashboard/discovery/__tests__/DiscoveryPageClient.test.tsx": {
"TS2305": 2
},
"src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.tsx": {
"TS2322": 18
},
"src/app/(dashboard)/dashboard/memory/components/tabs/MemoriesTab.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/omni-skills/OmniSkillsPageClient.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/OmniExecutionsTab.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/OmniMarketplaceTab.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSandboxTab.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillCard.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/OmniSkillsList.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/omni-skills/components/SkillInspectorPane.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/playground/components/PresetPicker.tsx": {
"TS2352": 1
},
"src/app/(dashboard)/dashboard/playground/components/ToolsBuilder.tsx": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/ProviderDetailPageClient.tsx": {
"TS2322": 4
},
"src/app/(dashboard)/dashboard/providers/[id]/__tests__/phase1e.test.tsx": {
"TS2741": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CompatibleModelsSection.tsx": {
"TS2741": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionRow.tsx": {
"TS2345": 3,
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ConnectionsListPanel.tsx": {
"TS2322": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/CustomModelsSection.tsx": {
"TS2739": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ModelCompatPopover.tsx": {
"TS2304": 5
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModalsPanel.tsx": {
"TS2322": 3,
"TS2739": 1,
"TS2345": 3
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderModelsSection.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderParamFilterSection.tsx": {
"TS2339": 6
},
"src/app/(dashboard)/dashboard/providers/[id]/components/ProviderPlaygroundPanel.tsx": {
"TS2503": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/components/__tests__/phase1d.test.tsx": {
"TS2739": 2
},
"src/app/(dashboard)/dashboard/providers/[id]/components/modals/EditConnectionModal.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelImportHandlers.ts": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/providers/[id]/hooks/useModelVisibilityHandlers.ts": {
"TS2339": 15
},
"src/app/(dashboard)/dashboard/providers/components/onboarding/providerOnboardingCatalog.ts": {
"TS2339": 4,
"TS2345": 2
},
"src/app/(dashboard)/dashboard/providers/providerPageUtils.ts": {
"TS2345": 1
},
"src/app/(dashboard)/dashboard/providers/services/components/CliproxyModelMappingEditor.tsx": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/quota/page.tsx": {
"TS2339": 4
},
"src/app/(dashboard)/dashboard/runtime/RuntimePageClient.tsx": {
"TS2304": 1
},
"src/app/(dashboard)/dashboard/settings/components/AppearanceTab.tsx": {
"TS2339": 4
},
"src/app/(dashboard)/dashboard/settings/components/RedisLauncherPanel.tsx": {
"TS2345": 11
},
"src/app/(dashboard)/dashboard/settings/components/SidebarTab.tsx": {
"TS2322": 1
},
"src/app/(dashboard)/dashboard/settings/components/proxy/FreePoolTab.tsx": {
"TS2304": 1
},
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CustomHostsManager.tsx": {
"TS2339": 1
},
"src/app/(dashboard)/dashboard/tools/traffic-inspector/components/tabs/ResponseBodyTab.tsx": {
"TS2339": 5
},
"src/app/(dashboard)/dashboard/translator/components/MonitorTab.tsx": {
"TS2345": 1
},
"src/app/(dashboard)/dashboard/translator/components/advanced/CompressionPreviewAccordion.tsx": {
"TS4104": 1
},
"src/app/(dashboard)/dashboard/translator/components/advanced/StreamTransformerAccordion.tsx": {
"TS2345": 1
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/index.tsx": {
"TS2339": 2
},
"src/app/(dashboard)/dashboard/usage/components/ProviderLimits/parts/QuotaEnvGroup.tsx": {
"TS2739": 1
},
"src/lib/combos/builderDraft.ts": {
"TS2741": 1
},
"src/lib/providers/codexFastTier.ts": {
"TS2367": 1
},
"src/lib/services/htmlRewriter.ts": {
"TS2322": 2,
"TS2345": 2
},
"src/mitm/inspector/sseMerger.ts": {
"TS2352": 1
},
"src/shared/components/Header.tsx": {
"TS2353": 1
},
"src/shared/components/MonacoEditor.tsx": {
"TS2307": 1
},
"src/shared/components/OAuthModal.tsx": {
"TS2769": 4,
"TS2345": 4
},
"src/shared/components/SkillsConceptCard.tsx": {
"TS2503": 1
},
"src/shared/components/analytics/charts.tsx": {
"TS2345": 1
},
"src/shared/components/analytics/rechartsDonuts.tsx": {
"TS2739": 2
},
"src/shared/hooks/useElectron.ts": {
"TS2339": 19
},
"src/shared/providers/webSessionCredentials.ts": {
"TS2353": 1,
"TS2322": 1
},
"src/shared/schemas/cliCatalog.ts": {
"TS2554": 2
},
"src/shared/services/opencodeConfig.ts": {
"TS2345": 1
}
}

View File

@@ -45,6 +45,7 @@
"concurrently",
"cross-env",
"csv-stringify",
"ctrf",
"dompurify",
"dpdm",
"electron",
@@ -63,6 +64,7 @@
"glob",
"http-proxy-middleware",
"https-proxy-agent",
"httpyac",
"husky",
"ink",
"ink-spinner",
@@ -74,6 +76,7 @@
"jscpd",
"jsdom",
"jsonc-parser",
"junit-to-ctrf",
"keytar",
"knip",
"license-checker-rseidelsohn",
@@ -99,7 +102,9 @@
"pino-abstract-transport",
"pino-pretty",
"playwright",
"playwright-ctrf-json-reporter",
"prettier",
"promptfoo",
"react",
"react-dom",
"react-is",

View File

@@ -0,0 +1,39 @@
{
"_meta": "Relative weights for scripts/quality/balance-e2e-shards.mjs (LPT shard packing). Unitless \u2014 only ratios matter. Seeded from spec LOC (proxy) on 2026-07-13; replace values with real per-file durations (seconds) from a full CI run's reports whenever convenient. New specs without an entry get the median weight.",
"a11y-resilience.spec.ts": 59,
"a11y.spec.ts": 281,
"agent-bridge-traffic-cross.spec.ts": 155,
"agent-bridge.spec.ts": 160,
"agent-skills-page.spec.ts": 205,
"analytics-tabs.spec.ts": 334,
"api-keys-flow.spec.ts": 643,
"api.spec.ts": 30,
"combo-live-studio.spec.ts": 35,
"combo-unification.spec.ts": 192,
"combos-flow.spec.ts": 629,
"compression-studio.spec.ts": 55,
"error-pages.spec.ts": 101,
"group-b-activity-feed.spec.ts": 96,
"group-b-quota-plans-config.spec.ts": 154,
"group-b-quota-share-pools.spec.ts": 98,
"group-b-redirect-logs-activity.spec.ts": 56,
"memory-engine.spec.ts": 607,
"memory-qdrant-routes.spec.ts": 360,
"memory-settings.spec.ts": 193,
"navigation.spec.ts": 28,
"playground-compare.spec.ts": 138,
"playground-studio.spec.ts": 101,
"protocol-visibility.spec.ts": 25,
"providers-bailian-coding-plan.spec.ts": 240,
"providers-management.spec.ts": 324,
"proxy-registry.smoke.spec.ts": 218,
"resilience-plan-alignment.spec.ts": 382,
"responsive.spec.ts": 21,
"search-tools-studio.spec.ts": 133,
"settings-toggles.spec.ts": 127,
"skills-marketplace.spec.ts": 209,
"smoke.spec.ts": 33,
"traffic-inspector.spec.ts": 212,
"translator-friendly.spec.ts": 115,
"visual-resilience-smoke.spec.ts": 20
}

View File

@@ -158,7 +158,8 @@
"dedicatedGate": true
},
"secretFindings": {
"value": 3,
"_note": "Zeroed 2026-07-13 (WS6/D3): the 3 frozen generic-api-key FPs are allowlisted with justification in .gitleaks.toml — any NEW finding regresses the ratchet.",
"value": 0,
"direction": "down",
"dedicatedGate": true
},
@@ -187,12 +188,6 @@
"dedicatedGate": true,
"_note": "oasdiff breaking-change gate (Fase 9 Onda 0). Blocks any breaking change vs base spec."
},
"semgrepFindings": {
"value": 0,
"direction": "down",
"dedicatedGate": true,
"_note": "semgrep owasp/secrets findings. ADVISORY until first CI value is frozen, then flip blocking (Fase 9)."
},
"mutationScore.src/sse/services/auth.ts": {
"value": 52.57,
"direction": "up",

View File

@@ -46,6 +46,7 @@ Runs on every PR to `main`. Blocks merge on failure.
| `check:docs-sync` | CHANGELOG version, OpenAPI version, and `llm.txt` are in sync | Yes |
| `typecheck:core` | TypeScript compilation without errors (advisory warnings only) | Yes |
| `typecheck:noimplicit:core` | Strict `noImplicitAny` — forward-looking; many pre-existing call sites still need annotations | **Advisory** (`continue-on-error: true`) |
| `check:dashboard-typecheck` | `tsc` scoped to `src/app/(dashboard)/**` (#7033) — `typecheck:core`'s curated 27-file allowlist does not include any dashboard TSX, and `next build` never type-checks it either (`next.config.mjs` sets `ignoreBuildErrors: true`), so orphaned-identifier regressions there (#6625/#6909) were invisible to CI. Diffs against a frozen per-file/per-TS-code count baseline (`config/quality/dashboard-typecheck-baseline.json`, same stale-enforcement pattern as `check:known-symbols`) — only NEW errors beyond the baselined count fail the gate; ratchet down with `--update` when a pre-existing error is fixed. | Yes |
### Job: `quality-gate`
@@ -173,6 +174,31 @@ pending implementation).
---
## Test Retry Policy (WS5.4, v3.8.49)
Retry is per-runner, never a global blanket — a blanket retry converts real regressions
into invisible flakes:
| Runner | Policy | Why |
| --- | --- | --- |
| Playwright (e2e) | `retries: 1` in CI only, with `trace: on-first-retry` | Browser/network timing is genuinely nondeterministic; one retry with a trace turns a flake into a diagnosable artifact |
| Vitest | NO global retry. A proven-flaky test gets an explicit per-test retry (visible in the diff, reviewed in PR) | Keeps the quarantine list in the repo, never opaque |
| node:test (unit) | NO retry, ever | A flaky unit test is a bug in the test — fix it, don't re-roll it |
Target SLOs once flake telemetry lands (WS5.2/5.3): <1% flake rate per test
("fix now" threshold), ≥95% pass rate per pipeline. Industry reference values —
recalibrate against our own measurements.
## Release-Level Ratchet Drift (WS5.5, v3.8.49)
When a ratchet (file-size, complexity, eslint warnings) regresses on the PURE release
tip — i.e. the COMBINATION of merges regressed it, and no single PR reproduces the
regression on its own branch — the fix belongs to the **release captain, once, on the
release branch**: prefer extraction/refactor; rebaseline only with the documented
justification entry. Never push combination drift onto a contributor PR, and never
rebaseline per-PR (that hides real regressions). Discriminate first: reproduce the
red against the pure tip in a probe worktree before assuming your PR caused it.
## Allowlist Policy
Every gate that cannot fail on pre-existing violations uses a frozen allowlist

View File

@@ -278,7 +278,8 @@ Every compressed request includes stats in the server logs:
| Phase 1 | Off, Lite | ✅ Shipped |
| Phase 2 | Standard, Aggressive, Ultra | ✅ Shipped |
| Phase 3 | RTK, Stacked, Compression Combos | ✅ Shipped |
| Phase 4 | Output Styles, SLM-tier Ultra, adaptive context-budget, eval harness | ✅ Shipped |
| Phase 4 | Output Styles, SLM-tier Ultra, eval harness | ✅ Shipped |
| Phase 4C | Adaptive context-budget ("dial") — compute engine + API (`contextBudget` on `PUT /api/settings/compression`) | ✅ Shipped (API-configurable; dashboard controls not yet built, #7005) |
---

View File

@@ -272,7 +272,7 @@ OmniRoute 提供两层防护:请求侧的注入扫描和响应侧的 PII 脱
| `OMNIROUTE_PUBLIC_URL` | _(未设置)_ | `open-sse/utils/kieTask.ts` | 用于组合异步回调 URL 的公共源。kie.ai 回调的最低优先级回退;也用作其他中继的通用公共 URL。 |
| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | Usage 页面使用的 CrofAI 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | Usage 页面使用的 OpenCode (zen/go) 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。可覆盖为中继/测试固定件。 |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(未设置)_ | `open-sse/services/opencodeOllamaUsage.ts` | Usage 页面使用的 OpenCode Go 配额查询端点。OpenCode Go 没有公开的配额 API因此没有默认值除非运维人员显式设置该变量选择接入自建/镜像端点,否则不会发起网络请求。 |
| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | 配置了 workspace ID 和 auth Cookie 时用于配额抓取的 OpenCode Go Dashboard 基础 URL。可覆盖为中继/测试固定件。 |
| `OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | 用于 Dashboard 配额抓取的 OpenCode Go workspace ID。配置多个账户时推荐使用每个连接的 Dashboard 字段。 |
| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(未设置)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID 环境变量的备选名,在较短的别名之前使用。配置多个账户时,推荐使用每个连接的 Dashboard 字段。 |

104
docs/ops/HOMOLOGATION.md Normal file
View File

@@ -0,0 +1,104 @@
---
title: "Homologation Suite (npm run homolog)"
version: 3.8.49
lastUpdated: 2026-07-14
---
# Homologation Suite (`npm run homolog`)
Real-environment E2E validation of the OmniRoute deploy running on the homologation VPS
(`HOMOLOG_BASE_URL`, e.g. `http://192.168.0.15:20128`). One command replaces the manual
release STOP #2 checklist with an automated, evidence-producing run.
## What it covers
| Layer | What it checks | Implementation |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- |
| L0 — health/parity | `/api/monitoring/health` responds `200` with `status: "healthy"` and the expected version | `scripts/homolog/lib/parity.mjs` |
| L1a — ephemeral key | Admin login → `POST /api/keys` creates a scoped API key for the run, revoked (`DELETE /api/keys/:id`) in a `finally` block regardless of outcome | `scripts/homolog/lib/adminClient.mjs` |
| L1b — API surface | `/v1/models` catalog, a real non-streaming chat completion (tier-critical model, `max_tokens: 5`), an invalid-key `401`, and public `/api/monitoring/health` | `tests/homolog/api/core.http` (httpYac) |
| L1c — SSE streaming | Real streaming chat completion; asserts `text/event-stream`, at least one content delta, and a `[DONE]` terminator | `scripts/homolog/lib/sseCheck.mjs` |
| L2 — real providers | One minimal-cost chat request per critical provider present in the live `/v1/models` catalog, generated on the fly via promptfoo | `scripts/homolog/gen-promptfoo.mjs` + `scripts/homolog/lib/providerTiers.mjs` |
| L4a — UI auth | Logs in once via the real login form and reuses the session (`storageState`) across the UI layer | `tests/homolog/ui/auth.setup.ts` |
| L4b — UI routes | Every static `page.tsx` under `src/app/(dashboard)/dashboard` (discovered from the filesystem, dynamic `[param]` routes skipped) loads without an HTTP error, a page error, or the Next.js error boundary | `tests/homolog/ui/routes.spec.ts` |
| L4c — UI critical flow | Creates an API key through the dashboard UI and revokes it again (leaves no residue on the VPS) | `tests/homolog/ui/api-key-flow.spec.ts` |
| L5 — unified report | Merges httpYac (via `junit-to-ctrf`), the promptfoo→CTRF adapter, and the Playwright CTRF reporter into one `homolog-ctrf.json`, plus a human-readable `homolog-report/summary.md` | `scripts/homolog/run.mjs` |
Zero LLM involvement in the replay itself — this is a deterministic regression battery,
not an eval. AI only enters in future maintenance work (see Roadmap below).
## Prerequisites
1. Copy `.env.homolog.example` to `.env.homolog` (gitignored — never commit it) and fill in:
- `HOMOLOG_BASE_URL` — the target deploy, e.g. `http://192.168.0.15:20128`.
- `HOMOLOG_ADMIN_PASSWORD` — the dashboard management password for that deploy.
- `HOMOLOG_CRITICAL_PROVIDERS` — comma-separated provider prefixes that get a real
smoke chat request (e.g. `openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter`).
- `HOMOLOG_API_KEY` — leave empty in normal runs; the suite creates and revokes its
own ephemeral key. Only set this to debug a single layer in isolation.
2. `npm install` in the repo (the suite's dependencies — `httpyac`, `promptfoo`,
`playwright-ctrf-json-reporter`, `junit-to-ctrf`, `ctrf` — are regular devDependencies).
3. `npx playwright install` if the browser binaries are not already present.
## How to run
```bash
npm run homolog
```
To validate against a deploy whose version does not match the local `package.json`
(e.g. a homologation box still on a previous patch release), override the expected
version explicitly:
```bash
HOMOLOG_EXPECT_VERSION=3.8.47 npm run homolog
```
The run exits non-zero if any layer fails, and always attempts to revoke the ephemeral
API key it created, even on failure (`finally` block in `scripts/homolog/run.mjs`).
## Reading the report
All output lands in `homolog-report/` (gitignored):
- `summary.md` — the same table printed to stdout, one row per layer (✅/❌ + detail).
- `homolog-ctrf.json` — the unified CTRF report (merge of API/SSE, provider-smoke, and
UI results) — this is the artifact to attach to a release STOP #2 checklist.
- `httpyac-junit.xml`, `api-ctrf.json`, `providers-ctrf.json`, `ui-ctrf.json` — the
per-layer raw/intermediate reports.
- `promptfooconfig.yaml`, `provider-misses.json` — the generated promptfoo config for
the current run and any critical providers that were missing from the live catalog.
A failing L0 aborts immediately (no ephemeral key is created) since a version/health
mismatch means every downstream layer would be validating the wrong deploy.
## Re-baselining when the UI changes legitimately
L4b (route smoke) and L4c (API-key UI flow) are driven by real DOM locators, not
snapshots, so most legitimate UI changes do not require any suite update. When a change
does break a locator (e.g. a renamed button label or a moved settings page):
1. Re-confirm the locator against the current source (the specs already document which
file/line each locator was confirmed against — follow the same pattern, don't guess).
2. Update the spec in `tests/homolog/ui/`.
3. Re-run `npm run homolog` (or just the affected Playwright spec) against the VPS to
confirm the fix, then commit.
There is no visual/pixel baseline in this suite (F1) — see Roadmap for that.
## Roadmap (F2 / F3)
Design and phased rollout live in the internal planning spec
`_tasks/superpowers/specs/2026-07-13-homolog-e2e-suite-design.md` (not linked — internal
`_tasks/` artifact, not part of this repo's tracked docs). Summary:
- **F2** — full walkthrough recording → Playwright Test Agents (`planner`/`generator`)
turn it into flow specs (create combo, test provider, edit settings, MCP tools) +
visual regression baseline (Lost Pixel) with masks over dynamic data (metrics,
timestamps, logs) + a `healer` maintenance routine per release.
- **F3** — resilience/contract/wiring coverage: toxiproxy + a fake OpenAI-compatible
provider on the devbox, a `homolog-resilience` combo on the VPS pointed at it
(injected timeout → assert fallback + circuit breaker open/close via
`/api/monitoring/health`); gated Schemathesis contract testing against
`docs/openapi.yaml` (low `--max-examples`, fixed seeds, non-LLM endpoints only); and
wiring `npm run homolog` + its `summary.md` into the `/generate-release` STOP #2 phase.

63
docs/ops/MERGE_TRAIN.md Normal file
View File

@@ -0,0 +1,63 @@
---
title: Merge Queue & Manual Merge-Train Runbook
---
# Merge Queue & Manual Merge-Train Runbook
Since v3.8.49 (WS3.2/WS3.4 of the quality/velocity plan) the default merge path for
reviewed PRs into `release/vX.Y.Z` is the **Mergify merge queue** (`.mergify.yml`);
the **manual merge-train** documented below is the FALLBACK — used during incidents,
release freezes, or if the Mergify Open Source plan ever changes.
## Default path: the Mergify queue
1. PR is reviewed/greened by the campaigns and approved by the owner's pre-merge ⭐
gate (the report + per-item decision — see `/merge-prs` Step 0.75).
2. The owner (or the session acting on the owner's decision) applies the **`queue`**
label. The label IS the merge approval; Mergify only executes it.
3. Mergify batches up to 10 queued PRs, validates the batch against the fast-gates,
and merges (squash). A red batch is **bisected automatically** — the offending PR
is isolated in ~log2(N) revalidations and unqueued; the rest proceed.
4. Post-merge, the continuous release-green workflow validates the new tip on push
and opens an attribution issue if the combination regressed (never auto-revert).
Guardrails (mirror `CLAUDE.md` Hard Rules #21/#22):
- **Release freeze open** → do NOT label PRs targeting the frozen branch; retarget to
the active `release/vX+1` first.
- **Another session's in-flight PR** → never label it; only the owning session queues
its own work.
- Tests-only diffs and `hotfix`-labeled PRs already run reduced CI (see
`RELEASE_CHECKLIST.md` → Hotfix Fast-Lane); the queue conditions accept whatever
check set actually ran (`#check-failure=0` + `#check-pending=0`).
## Fallback: the manual merge-train
Used when the queue is unavailable. This codifies the practice that drained 33 PRs in
one day during the v3.8.47 cycle:
1. **Assemble the batch** (~1030 reviewed+approved PRs). Check `linked:` collisions
(same `tap.testFiles`, same CHANGELOG hunks) and serialize those.
2. **Validate ONCE**: in an isolated worktree off the release tip, merge all batch
heads locally, then run the release-equivalent suite
(`npm run check:release-green`, add `--with-build` before a release).
3. **Green** → merge the PRs in sequence (re-checking `state,headRefOid` before each —
a PR whose head moved re-enters review). Prove the net diff of each merge is the
PR's own change (no auto-resolve reverts: audit `git diff --stat` for
out-of-scope deletions).
4. **Red** → bisect the batch by halves (validate each half) instead of re-validating
one-by-one; drop the offending PR back to the review queue with the evidence.
5. **Never**: merge during a freeze into the frozen branch; `git stash` anywhere;
blanket-rerun CI hoping a red goes away (rule: a red is information).
## Tiering (why the queue is safe with fast-gates only)
- **Per PR** (quality.yml fast-gates): TIA-impacted tests + full unit 4-shard +
vitest + lint bag + typecheck + docs/changelog integrity.
- **Per batch/tip** (continuous release-green): `--quick` HARD gates on every push to
the release branch; full `--with-build --full-ci` sweeps 3×/day.
- **Per release** (ci.yml on the release PR): the complete matrix incl. E2E ×9,
package-artifact + tarball boot-smoke, coverage/ratchets.
Nothing is validated less than before — the heavy surface just runs per batch/tip
instead of per PR, which is what removes the O(N) round-trips.

View File

@@ -37,6 +37,56 @@ npm run test:e2e # optional but recommended
/capture-release-evidences-cc
```
## npm Staged Publishing (default since v3.8.49 — WS1.3/D2)
The npm-publish workflow no longer publishes directly: it boots the packed tarball
(`check:pack-boot`) and then runs `npm stage publish` — the exact bytes are parked on
the registry, **not installable** until the owner approves. The human 2FA gate moved
to AFTER the proof, not before it.
**Owner flow after the workflow goes green:**
1. `npm stage list omniroute` — find the stage id (also printed in the workflow summary).
2. Verify the staged bytes (recommended): `npm stage download <id>`, then install the
downloaded tarball into a temp prefix and boot it (`npm run check:pack-boot` automates
the same pack→install→boot verdict in CI).
3. `npm stage approve <id>` — the 2FA prompt IS the publish. `npm stage reject <id>` discards.
4. Post-publish net: the post-publish verifier (WS1.4 of the v3.8.49 plan) installs the
published version from the public registry in a clean container and boots it.
**Emergency fallback:** `workflow_dispatch` with `publish_mode=direct` restores the
legacy immediate `npm publish` (use only if staging itself misbehaves; record why).
**One-time hardening (owner, npmjs.com):** configure the Trusted Publisher for
`omniroute` in stage-only mode so a leaked long-lived token cannot `npm publish`
directly from anywhere — CI can only stage; only the owner's 2FA releases.
**Broken-artifact playbook (unchanged):** `npm deprecate omniroute@<bad> "<reason> — use <fixed>"`
as the default reflex (minutes, reversible); `npm unpublish` only inside the 72h/no-dependents
window and never as the first move. Docker: never rewrite a version tag — rollback is
repointing `latest` to the last good digest.
## Hotfix Fast-Lane (label `hotfix`)
A PR labeled `hotfix` skips the heavy CI matrix (9-shard E2E, coverage ratchet,
quality-gate, quality-extended) and keeps the fast, high-signal gates: build,
unit shards, integration, vitest, lint/typecheck, docs-sync, `check:pack-artifact`
and the tarball boot-smoke (`check:pack-boot`). Target: green in ≤15min instead of ~33min.
**Entry policy — all four required (modeled on Chromium/VS Code/Node emergency lanes):**
1. **Severity**: production is broken — a published artifact crashes on boot / a
security fix / every user of the release is affected. "Important" is not "broken".
2. **Authority**: only the repository owner applies the `hotfix` label. The label IS
the approval — never self-serve on a campaign PR.
3. **Evidence**: the PR body links the previous fully-green heavy run (the suite the
skipped jobs would re-validate) plus the fix's own failing-then-passing test.
4. **Scope**: cherry-pick-only — the minimal fix, no refactors, no ride-alongs.
The skipped coverage/ratchet surface is re-validated by the next full run on the
release branch (continuous release-green) — the lane skips WAITING, never validation.
Tests-only diffs (all files under `tests/`, none under `tests/e2e/`) skip the E2E
matrix automatically, without any label.
## Detailed Checklist
### Pre-release

35
docs/ops/RUNNER_BOX.md Normal file
View File

@@ -0,0 +1,35 @@
---
title: Self-Hosted Runner Box Operations
---
# Self-Hosted Runner Box Operations (.113 pool)
The self-hosted pool (`self-hosted, omni-release` labels) runs on the 16 GB box at
`192.168.0.113`. Two failure modes recurred on release days and were, until v3.8.49,
manual discipline; the **janitor script codifies them** (WS3.3 of the quality plan):
1. **Orphaned temp/work dirs** filling the disk → disk-full SQLite errors mid-job.
2. **>4 concurrent runners** → OOM-killed jobs (8-wide killed jobs twice on the
v3.8.47 release day; 4-wide is the proven ceiling).
## Install the janitor (one-time, on the box)
```bash
sudo mkdir -p /opt/omniroute-ops
sudo cp scripts/ops/runner-janitor.sh /opt/omniroute-ops/
sudo chmod +x /opt/omniroute-ops/runner-janitor.sh
( sudo crontab -l 2>/dev/null; echo '*/30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1' ) | sudo crontab -
```
What it does every 30min: sweeps runner temp leftovers older than 24h, alerts at
≥85% root-disk usage, and alerts when more than the runner ceiling (default 4, tunable
via the script's own environment) of `Runner.Listener` processes are up. Alerts land in `/var/log/runner-janitor.log`
with a non-zero exit (grep for `⚠`).
## Operating rules
- **Ceiling: 4 runners** on the 16 GB box. Runners 58 stay STOPPED except for
explicit off-peak experiments — never during a release window.
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop`
only when its runner is idle (`Runner.Listener` without a `Runner.Worker` child).
- The `.15` VPS is homologation-only — never runs CI runners.

View File

@@ -280,7 +280,7 @@ OmniRoute provides a two-layer defense: request-side injection scanning and resp
| `OMNIROUTE_PUBLIC_URL` | _(unset)_ | `open-sse/utils/kieTask.ts` | Public origin used to compose async callback URLs. Lowest-priority fallback for kie.ai callbacks; also used as a generic public URL for other relays. |
| `OMNIROUTE_CROF_USAGE_URL` | `https://crof.ai/usage_api/` | `open-sse/services/usage.ts` | CrofAI quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_QUOTA_URL` | `https://opencode.ai/zen/go/v1/quota` | `open-sse/services/opencodeQuotaFetcher.ts` | OpenCode (zen/go) quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | `https://api.z.ai/api/monitor/usage/quota/limit` | `open-sse/services/usage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. Override for relays / test fixtures. |
| `OMNIROUTE_OPENCODE_GO_QUOTA_URL` | _(unset)_ | `open-sse/services/opencodeOllamaUsage.ts` | OpenCode Go quota lookup endpoint used by the Usage page. OpenCode Go has no public quota API, so this has no default and the network call is skipped unless the operator opts in to a self-hosted/mirrored endpoint. |
| `OMNIROUTE_OPENCODE_GO_DASHBOARD_URL` | `https://opencode.ai/workspace` | `open-sse/services/usage.ts` | OpenCode Go dashboard base URL used for quota scraping when a workspace ID and auth cookie are configured. Override for relays / test fixtures. |
| `OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | OpenCode Go workspace ID used for dashboard quota scraping. Prefer the per-connection Dashboard field when multiple accounts are configured. |
| `OMNIROUTE_OPENCODE_GO_WORKSPACE_ID` | _(unset)_ | `open-sse/services/usage.ts` | Alternate OpenCode Go workspace ID env var used before the shorter alias. Prefer the per-connection Dashboard field when multiple accounts are configured. |

View File

@@ -128,11 +128,25 @@ const nextConfig = {
// expected diagnostic — suppress it here rather than fight the analyzer,
// mirroring the isNextIntlExtractorDynamicImportWarning precedent below
// for the webpack path. (#6582)
// open-sse/services/compression/ruleLoader.ts and
// .../engines/rtk/filterLoader.ts both define an identical
// getModuleDir() helper that walks up directories via
// path.resolve(anchor) + fs.existsSync(...) in a loop with a
// non-literal argument — the same dynamic-path fs access pattern as
// the agentSkills case above, but not covered by that narrower
// allowlist glob, so the "Overly broad patterns..." warning kept
// firing (610 times, once per entry point transitively importing the
// compression module). Same known-benign, bounded fs access;
// suppressed here rather than fought. (#7051, follow-up to #6582)
ignoreIssue: [
{
path: "**/src/lib/agentSkills/**",
description: /Overly broad patterns can lead to build performance issues/,
},
{
path: "**/open-sse/services/compression/**",
description: /Overly broad patterns can lead to build performance issues/,
},
],
},
output: "standalone",

View File

@@ -23,29 +23,15 @@ export const opencodeProvider: RegistryEntry = {
interleavedField: "reasoning_content",
},
{ id: "deepseek-v4-flash-free", name: "DeepSeek V4 Flash Free", supportsReasoning: true },
// #3110: MiniMax M3 free tier via OpenCode
// #3328: MiniMax M3 is multimodal (verified: describes base64 images via the
// opencode upstream) — flag it so vision requests aren't gated/stripped.
{
id: "minimax-m3-free",
name: "MiniMax M3 Free",
contextLength: 1048576,
supportsVision: true,
},
{ id: "minimax-m2.5-free", name: "MiniMax M2.5 Free", contextLength: 204800 },
{ id: "ling-2.6-1t-free", name: "Ling 2.6 Free", contextLength: 262000 },
{
id: "trinity-large-preview-free",
name: "Trinity Large Preview Free",
contextLength: 131000,
},
{ id: "nemotron-3-super-free", name: "Nemotron 3 Super Free", contextLength: 1000000 },
{
id: "qwen3.6-plus-free",
name: "Qwen3.6 Plus Free",
targetFormat: "claude",
supportsVision: false,
contextLength: 200000,
},
// #6998: 2026-07-14 refresh — the upstream free tier rotated its lineup;
// minimax-m3-free, minimax-m2.5-free, ling-2.6-1t-free,
// trinity-large-preview-free, nemotron-3-super-free and qwen3.6-plus-free
// were delisted (401 "Model X is not supported") and replaced by the 4
// entries below, confirmed live against
// https://opencode.ai/zen/v1/chat/completions.
{ id: "mimo-v2.5-free", name: "MiMo V2.5 Free", contextLength: 131000 },
{ id: "hy3-free", name: "HY3 Free", contextLength: 131000 },
{ id: "nemotron-3-ultra-free", name: "Nemotron 3 Ultra Free", contextLength: 1000000 },
{ id: "north-mini-code-free", name: "North Mini Code Free", contextLength: 131000 },
],
};

View File

@@ -64,11 +64,18 @@ function shouldUseBrowserBacked(): boolean {
interface DuckDuckGoVqdHeaders {
vqd4: string | null;
vqdHash1: string | null;
// #6996: the real upstream HTTP status of the VQD-acquisition attempt (null when
// no request was made / a network error was thrown). Lets execute() distinguish a
// retryable 429 rate-limit from a genuine 5xx instead of collapsing both to 503.
status: number | null;
retryAfter: string | null;
}
interface DuckDuckGoAuthHeaders {
vqd4: string | null;
vqdHash1: string | null;
status: number | null;
retryAfter: string | null;
}
interface DuckDuckGoModelCapabilities {
@@ -369,10 +376,13 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
const isStreaming = stream !== false;
const upstreamHeaders = upstreamExtraHeaders || {};
const errorResponse = (status: number, message: string): Response =>
const errorResponse = (status: number, message: string, retryAfter?: string | null): Response =>
new Response(JSON.stringify({ error: { message } }), {
status,
headers: { "Content-Type": "application/json" },
headers: {
"Content-Type": "application/json",
...(retryAfter ? { "Retry-After": retryAfter } : {}),
},
});
if (messages.length === 0) {
@@ -468,6 +478,19 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
const vqdHeaders = await this.acquireAuthHeaders(mergedSignal);
if (!vqdHeaders.vqd4 && !vqdHeaders.vqdHash1) {
clearTimeout(timeout);
// #6996: surface the real upstream status instead of a hardcoded 503 so a
// 429 rate-limit gets a connection-cooldown, not a whole-provider circuit
// breaker trip (see CLAUDE.md "Provider Circuit Breaker" — only
// 408/500/502/503/504 should trip it, not 429). Any other non-2xx status
// (403 anti-bot challenge, genuine 5xx, or a thrown network error where
// status is null) keeps the existing 503 fallback.
if (vqdHeaders.status === 429) {
return errorResponse(
429,
"Failed to acquire VQD token: upstream rate limited",
vqdHeaders.retryAfter
);
}
return errorResponse(503, "Failed to acquire VQD token");
}
@@ -555,16 +578,25 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
});
this.rememberResponseCookies(resp);
if (!resp.ok) return { vqd4: null, vqdHash1: null };
if (!resp.ok) {
return {
vqd4: null,
vqdHash1: null,
status: resp.status,
retryAfter: resp.headers.get("Retry-After"),
};
}
return {
vqd4: resp.headers.get("x-vqd-4"),
vqdHash1: resp.headers.get("x-vqd-hash-1"),
status: resp.status,
retryAfter: null,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw error;
}
return { vqd4: null, vqdHash1: null };
return { vqd4: null, vqdHash1: null, status: null, retryAfter: null };
}
}
@@ -576,6 +608,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
return {
vqd4: null,
vqdHash1: await solveDuckDuckGoChallenge(challenge, FAKE_HEADERS["User-Agent"]),
status: null,
retryAfter: null,
};
} catch (error) {
void error;
@@ -588,6 +622,8 @@ export class DuckDuckGoWebExecutor extends BaseExecutor {
return {
vqd4: headers.vqd4,
vqdHash1: await solveDuckDuckGoChallenge(headers.vqdHash1, FAKE_HEADERS["User-Agent"]),
status: headers.status,
retryAfter: headers.retryAfter,
};
} catch (error) {
void error;

View File

@@ -112,12 +112,15 @@ function parseCookies(raw: string): Array<{ name: string; value: string }> {
* [["wrb.fr", null, "<JSON string>"]]
*
* The JSON string contains nested array: inner[4][0][1] = ["text chunks"].
* We concatenate text from every wrb.fr line because Gemini can split one
* assistant answer across multiple StreamGenerate chunks.
* Each wrb.fr line is a CUMULATIVE snapshot of the whole answer generated so
* far (not an independent delta), so we keep only the text from the LAST
* frame that yields non-empty text instead of concatenating every frame —
* concatenating would reproduce the same growing text with each snapshot
* (see #7163).
*/
export function parseStreamResponse(raw: string): string {
const lines = raw.split("\n");
const textChunks: string[] = [];
let lastText = "";
for (const rawLine of lines) {
const line = rawLine.trim();
@@ -133,12 +136,12 @@ export function parseStreamResponse(raw: string): string {
const responseArray = inner?.[4]?.[0]?.[1];
if (!Array.isArray(responseArray)) continue;
const text = responseArray.filter((c: unknown) => typeof c === "string").join("");
if (text) textChunks.push(text);
if (text) lastText = text;
} catch {
// Skip unparseable lines
}
}
return textChunks.join("");
return lastText;
}
function readCredentialString(value: unknown): string {

View File

@@ -170,8 +170,26 @@ const executors = {
const defaultCache = new Map();
// #6699 — providers that exist ONLY as Cloud Agent task-API entries
// (CLOUD_AGENT_PROVIDERS / staticModels "Available Models" catalog) and have no
// chat-completions REGISTRY entry anywhere in open-sse/. Without this guard,
// getExecutor() silently falls through to DefaultExecutor's
// `PROVIDERS[provider] || PROVIDERS.openai` fallback, sending the user's real
// provider key to OpenAI's endpoint (mislabeled as coming from the provider the
// user actually selected). Starting with just "jules" (the reported case);
// "devin" and "codex-cloud" share the same structural gap and are left for a
// follow-up once their own chat-routing behavior is confirmed.
const CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS = new Set(["jules"]);
export function getExecutor(provider) {
if (executors[provider]) return executors[provider];
if (CHAT_UNSUPPORTED_CLOUD_AGENT_PROVIDERS.has(provider)) {
const err = new Error(
`Provider "${provider}" is a cloud-agent provider and does not support direct chat completions; use the Cloud Agents task API instead.`
);
(err as Error & { status?: number }).status = 400;
throw err;
}
if (!defaultCache.has(provider)) defaultCache.set(provider, new DefaultExecutor(provider));
return defaultCache.get(provider);
}

View File

@@ -90,6 +90,7 @@ export interface AccountProxyConfig {
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}

View File

@@ -21,6 +21,7 @@ export interface OpencodeAccountProxyConfig {
port: number;
username?: string;
password?: string;
relayAuth?: string;
} | null;
}

View File

@@ -106,8 +106,13 @@ function buildPrompt(messages: Array<Record<string, unknown>>): string {
/** Build the `hy_source=web; hy_user=...; hy_token=...` cookie from the pasted header. */
function buildYuanbaoCookie(rawApiKey: string): { cookie: string; hasToken: boolean } {
const raw = stripCookieInputPrefix(rawApiKey || "");
const hyUser = extractCookieValue(raw, "hy_user");
const hyToken = extractCookieValue(raw, "hy_token");
// Guard the extractCookieValue bare-value fallback: for input that is a single
// FOREIGN pair (e.g. "some_other=abc") the helper returns the whole string, which
// used to fool this validation into forwarding garbage upstream (the request only
// failed when Tencent replied 401 — a live-network dependency). Yuanbao needs the
// two distinct cookies, so only trust an extraction the input explicitly names.
const hyUser = raw.includes("hy_user=") ? extractCookieValue(raw, "hy_user") : null;
const hyToken = raw.includes("hy_token=") ? extractCookieValue(raw, "hy_token") : null;
if (hyUser && hyToken) {
return { cookie: `hy_source=web; hy_user=${hyUser}; hy_token=${hyToken}`, hasToken: true };

View File

@@ -41,6 +41,7 @@ import {
isSubscriptionQuotaText,
buildSubscriptionQuotaFallback,
buildWeeklyQuotaFallback,
buildSessionQuotaFallback,
} from "./quotaTextCooldowns.ts";
import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
@@ -1454,6 +1455,12 @@ export function checkFallbackError(
}
const weeklyResult = buildWeeklyQuotaFallback(errorStr);
if (weeklyResult) return weeklyResult;
// Issue #7071 (session usage cap) is the same sibling gap as #3709 above —
// runs UNCONDITIONALLY for the same reason: apikey-category providers
// like ollama-cloud are excluded from the oauth-only shouldUseQuotaSignal
// gate.
const sessionResult = buildSessionQuotaFallback(errorStr);
if (sessionResult) return sessionResult;
const quotaResetHintMs = parseRetryFromErrorText(errorStr);
if (

View File

@@ -344,7 +344,19 @@ function toHeaders(raw: Record<string, string[]>): Headers {
// to a file path, terminating when the upstream sends `streamOutputEOFSymbol`.
// We tail the file from a worker and surface the bytes as a ReadableStream.
async function tlsFetchStreaming(
// Cap for the bounded fallback read of a non-SSE error body straight from the
// streaming temp file (mirrors the 2048-byte cap executors/claude-web.ts
// already applies when reading error bodies) — avoids buffering an unbounded
// error page into memory. See #7134.
const MAX_ERROR_BODY_BYTES = 16 * 1024;
/**
* Exported for tests (issue #7134): allows injecting a fake `client` so the
* non-SSE error-body fallback path can be exercised without
* `--experimental-test-module-mocks`, matching the DI pattern already used
* by `__setTlsFetchOverrideForTesting` for the outer `tlsFetchClaude`.
*/
export async function tlsFetchStreaming(
client: { request: (url: string, opts: Record<string, unknown>) => Promise<TlsResponseLike> },
url: string,
requestOptions: Record<string, unknown>,
@@ -417,11 +429,22 @@ async function tlsFetchStreaming(
const r = await requestPromise.catch(
(e) => ({ status: 502, headers: {}, body: String(e) }) as TlsResponseLike
);
// tls-client-node's `streamOutputPath` mode writes the response body to
// the temp file chunk-by-chunk and does NOT also populate the resolved
// response's in-memory `body` field (confirmed against
// node_modules/tls-client-node/dist/response.js) — so for every non-SSE,
// non-2xx claude-web response (400/403/429/500 with a real JSON/HTML
// error), `r.body` is empty even though the real bytes are sitting in
// `path` (we just peeked them above). Prefer `r.body` when it IS
// populated (some native-client modes do fill it in); otherwise fall
// back to a bounded read of the temp file so the real upstream error
// detail reaches the caller instead of being silently discarded. #7134
const text = r.body || (await readFirstBytes(path, MAX_ERROR_BODY_BYTES).catch(() => ""));
await cleanupTempPath(path);
return {
status: r.status,
headers: toHeaders(r.headers),
text: r.body,
text,
body: null,
};
}

View File

@@ -13,8 +13,13 @@ type UsageQuota = {
currency?: string;
};
const OPENCODE_GO_QUOTA_URL =
process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL ?? "https://api.z.ai/api/monitor/usage/quota/limit";
// OpenCode Go does not expose a public quota API. There is no working
// opencode.ai endpoint to default to (see #7022) — the quota-by-API-key path
// below is opt-in only and activates exclusively when the operator sets
// OMNIROUTE_OPENCODE_GO_QUOTA_URL explicitly. Never hardcode a third-party
// host here (a previous default silently sent the user's API key to an
// unrelated Z.AI endpoint).
const OPENCODE_GO_QUOTA_URL = process.env.OMNIROUTE_OPENCODE_GO_QUOTA_URL?.trim() || "";
const OPENCODE_GO_DASHBOARD_BASE_URL =
process.env.OMNIROUTE_OPENCODE_GO_DASHBOARD_URL ?? "https://opencode.ai/workspace";
const OPENCODE_GO_QUOTA_TOTALS = { session: 12, weekly: 30, mcp_monthly: 60 } as const;
@@ -335,6 +340,15 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
};
}
if (!OPENCODE_GO_QUOTA_URL) {
return {
message:
"OpenCode Go does not expose a public quota API. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping, " +
"or set OMNIROUTE_OPENCODE_GO_QUOTA_URL to opt in to an explicit quota endpoint.",
};
}
try {
const res = await fetch(OPENCODE_GO_QUOTA_URL, {
headers: {
@@ -348,7 +362,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
if (res.status === 401 || res.status === 403) {
return {
message:
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
};
}
@@ -374,7 +389,8 @@ export async function getOpenCodeGoUsage(apiKey: string, providerSpecificData?:
) {
return {
message:
"OpenCode Go API key is valid for chat/models but cannot read quota from the Z.AI quota API. " +
"OpenCode Go API key is valid for chat/models but cannot read quota from the configured " +
"OMNIROUTE_OPENCODE_GO_QUOTA_URL endpoint. " +
"Set OPENCODE_GO_WORKSPACE_ID and OPENCODE_GO_AUTH_COOKIE to enable dashboard quota scraping.",
};
}

View File

@@ -103,3 +103,37 @@ export function buildWeeklyQuotaFallback(errorStr: string): QuotaTextFallback |
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
// ─── Issue #7071 — Ollama Cloud 5-hour SESSION usage cap ───────────────────
//
// Ollama Cloud also enforces a rolling 5-hour "session" usage cap, sibling to
// the weekly cap above (#3709/#6638). On cap the upstream returns 429 with a
// body like "you (<account>) have reached your session usage limit". Same
// root cause as the weekly gap: neither the generic subscription-quota-text
// classifier nor the weekly one recognize "session" wording, so the account
// fell through to the generic 429 backoff and got retried within the same
// 5-hour window instead of cooling down for it — combo/LKGP routing cycled
// back to the "exhausted" account instead of advancing to the next one.
//
// Patterns are scoped to "session ... usage limit" / "session limit reached"
// / "reached your session ... usage limit" phrasing (not a bare "session"
// match) so unrelated "session expired"/"session token invalid" auth errors
// from other providers are not misclassified as quota-exhausted.
const SESSION_QUOTA_COOLDOWN_MS = 5 * 60 * 60 * 1000; // 5 hours
export function isSessionUsageLimitText(lower: string): boolean {
return (
lower.includes("session usage limit") ||
lower.includes("session limit reached") ||
(lower.includes("reached your session") && lower.includes("usage limit"))
);
}
export function buildSessionQuotaFallback(errorStr: string): QuotaTextFallback | null {
if (!isSessionUsageLimitText(errorStr.toLowerCase())) return null;
return {
shouldFallback: true,
cooldownMs: SESSION_QUOTA_COOLDOWN_MS,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}

View File

@@ -145,6 +145,22 @@ function clampDiagStr(v: unknown, max = 128): string {
return typeof v === "string" ? v.slice(0, max).replace(/[\r\n]+/g, " ") : "";
}
/**
* HTTP header values must be Latin1/ByteString (undici throws a TypeError
* otherwise — see #6612). Replace any codepoint outside the Latin1 range
* (0-255) with "?" so header construction never throws. Only used for the
* literal header value; the JSON body keeps the original, unsanitized
* readable text via `sanitizeComboDiagnostics`.
*/
function toHeaderSafeAscii(v: string): string {
let out = "";
for (let i = 0; i < v.length; i++) {
const code = v.charCodeAt(i);
out += code > 255 ? "?" : v[i];
}
return out;
}
/**
* Whitelist projection — guarantees only id/reason string primitives + integer
* counts can escape, regardless of what the caller assembled. This is the secret
@@ -186,10 +202,12 @@ export function errorResponseWithComboDiagnostics(
if (opts.code) body.error.code = opts.code;
if (opts.type) body.error.type = opts.type;
body.diagnostics = safe;
const excludedHeader = safe.excluded
.map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`)
.join(",")
.slice(0, 900);
const excludedHeader = toHeaderSafeAscii(
safe.excluded
.map((e) => `${e.provider}${e.model ? `/${e.model}` : ""}:${e.reason}`)
.join(",")
.slice(0, 900)
);
return new Response(JSON.stringify(body), {
status: statusCode,
headers: {
@@ -197,7 +215,7 @@ export function errorResponseWithComboDiagnostics(
"x-omniroute-combo-pool-size": String(safe.poolSize),
"x-omniroute-combo-attempted": String(safe.attempted),
"x-omniroute-combo-excluded": excludedHeader,
"x-omniroute-combo-terminal-reason": safe.terminalReason.slice(0, 200),
"x-omniroute-combo-terminal-reason": toHeaderSafeAscii(safe.terminalReason.slice(0, 200)),
},
});
}

9819
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -87,6 +87,7 @@
"build:release": "rm -rf .build dist && OMNIROUTE_BUILD_SHA=$(git rev-parse --short HEAD) npm run build && npm run build:cli && node scripts/build/write-build-sha.mjs",
"build:native:tproxy": "cd src/mitm/tproxy/native && npx --yes node-gyp rebuild",
"start": "node scripts/dev/run-next.mjs start",
"homolog": "node scripts/homolog/run.mjs",
"lint": "eslint . --cache --cache-location .eslintcache --suppressions-location config/quality/eslint-suppressions.json",
"lint:json": "node scripts/quality/run-eslint-json.mjs",
"lint:md": "npx --yes markdownlint-cli2 \"docs/**/*.md\" \"*.md\" \"!docs/i18n\" \"!docs/research\"",
@@ -129,6 +130,7 @@
"i18n:check-ui-coverage": "node scripts/i18n/check-ui-keys-coverage.mjs",
"check:node-runtime": "node --import tsx scripts/check/check-supported-node-runtime.ts",
"check:pack-artifact": "node --import tsx scripts/build/validate-pack-artifact.ts",
"check:pack-boot": "node scripts/check/check-pack-boot.mjs",
"check:pack-policy": "node --import tsx scripts/build/validate-pack-artifact.ts --policy-only",
"check:cli-i18n": "node scripts/check/check-cli-i18n.mjs",
"check:openapi-coverage": "node scripts/check/check-openapi-coverage.mjs",
@@ -183,6 +185,7 @@
"audit:electron": "npm --prefix electron audit --audit-level=critical && (npm --prefix electron audit --audit-level=high || echo '::warning::electron high-severity advisories present (non-blocking)')",
"typecheck:core": "tsc --pretty false -p tsconfig.typecheck-core.json",
"typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json",
"check:dashboard-typecheck": "node scripts/check/check-dashboard-typecheck.mjs",
"backfill-aggregation": "node --import tsx src/scripts/backfillAggregation.ts",
"env:sync": "node scripts/dev/sync-env.mjs",
"test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts \"tests/integration/combo-matrix/*.test.ts\"",
@@ -328,21 +331,26 @@
"c8": "^11.0.0",
"concurrently": "^10.0.3",
"cross-env": "^10.1.0",
"ctrf": "^0.2.1",
"dpdm": "^4.2.0",
"eslint": "^9.39.4",
"eslint-config-next": "16.2.10",
"eslint-plugin-sonarjs": "^4.1.0",
"fast-check": "^4.8.0",
"glob": "^13.0.6",
"httpyac": "^6.16.7",
"husky": "^9.1.7",
"jscpd": "^4.2.5",
"jsdom": "^29.1.1",
"junit-to-ctrf": "^0.0.14",
"knip": "^6.18.0",
"license-checker-rseidelsohn": "^5.0.1",
"lint-staged": "^17.0.8",
"lockfile-lint": "^5.0.0",
"node-loader": "^2.1.0",
"playwright-ctrf-json-reporter": "^0.0.29",
"prettier": "^3.8.3",
"promptfoo": "^0.121.18",
"size-limit": "^12.1.0",
"tailwindcss": "^4.3.0",
"type-coverage": "^2.29.7",

View File

@@ -160,6 +160,12 @@ export const PACK_ARTIFACT_REQUIRED_PATHS: string[] = [
"dist/head-response-guard.cjs",
"dist/webdav-handler.mjs",
"bin/cli/program.mjs",
// Direct imports of bin/omniroute.mjs — bin/cli/ is only an allowlist PREFIX, so a
// file vanishing from the tarball never fails the unexpected-paths check; only these
// required entries make its absence loud (#7065 class; derived + enforced by
// tests/unit/pack-artifact-entrypoint-closures.test.ts).
"bin/cli/data-dir.mjs",
"bin/cli/utils/storageKeyProvision.mjs",
"bin/mcp-server.mjs",
"bin/nodeRuntimeSupport.mjs",
"bin/omniroute.mjs",

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env node
// scripts/check/check-dashboard-typecheck.mjs
// Dashboard-scoped typecheck gate (#7033).
//
// `typecheck:core` (the only blocking CI typecheck gate) runs against a curated
// 27-file `"files"` allowlist in tsconfig.typecheck-core.json — none of it lives
// under `src/app/(dashboard)`, and `next.config.mjs` sets
// `typescript.ignoreBuildErrors: true`, so `next build` never type-checks either.
// Net effect: orphaned-identifier regressions in dashboard TSX (deleted `useState`
// decls with live usages left behind) are invisible to both CI type-check paths
// and only surface at runtime — exactly what happened in #6625/#6909.
//
// This gate runs `tsc` scoped to `src/app/(dashboard)/**/*.{ts,tsx}` via
// tsconfig.typecheck-dashboard.json and diffs the result against a frozen
// per-file/per-TS-code count baseline (config/quality/dashboard-typecheck-baseline.json),
// following this repo's stale-enforcement allowlist convention (see
// scripts/check/check-known-symbols.ts). A live count that EXCEEDS the baselined
// count for a given (file, TS code) pair is a regression and fails the gate; a
// live count that is lower is an improvement and does not fail (use --update to
// ratchet the baseline down).
//
// Run:
// node scripts/check/check-dashboard-typecheck.mjs
// node scripts/check/check-dashboard-typecheck.mjs --update # re-freeze baseline
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const ROOT = process.cwd();
const TSCONFIG = path.join(ROOT, "tsconfig.typecheck-dashboard.json");
const BASELINE_PATH = path.join(ROOT, "config/quality/dashboard-typecheck-baseline.json");
const UPDATE = process.argv.includes("--update");
// Matches tsc --pretty false output lines, e.g.:
// src/app/(dashboard)/dashboard/foo.tsx(12,7): error TS2304: Cannot find name 'bar'.
const TSC_ERROR_LINE = /^(.+?)\((\d+),(\d+)\): error (TS\d+):/;
/**
* Parses raw `tsc --pretty false` stdout into a nested count map:
* { "<relative file path>": { "<TS code>": <count> } }
*
* Pure/exported for unit testing against synthetic tsc output — no child
* process involved here.
*/
export function parseTscOutput(raw) {
const counts = {};
const lines = String(raw).split("\n");
for (const line of lines) {
const match = TSC_ERROR_LINE.exec(line);
if (!match) continue;
const [, file, , , code] = match;
if (!counts[file]) counts[file] = {};
counts[file][code] = (counts[file][code] || 0) + 1;
}
return counts;
}
/**
* Compares live (file, TS code) error counts against a frozen baseline.
* Returns `{ regressions, improvements }`:
* - regressions: entries where live count > baselined count (or the pair is
* entirely new/unbaselined) — these fail the gate.
* - improvements: entries where live count < baselined count — informational,
* do not fail (use --update to ratchet the baseline down).
*
* Exported for unit testing.
*/
export function diffAgainstBaseline(live, baseline) {
const regressions = [];
const improvements = [];
for (const [file, codes] of Object.entries(live)) {
for (const [code, liveCount] of Object.entries(codes)) {
const baselineCount = (baseline[file] && baseline[file][code]) || 0;
if (liveCount > baselineCount) {
regressions.push({ file, code, liveCount, baselineCount });
} else if (liveCount < baselineCount) {
improvements.push({ file, code, liveCount, baselineCount });
}
}
}
for (const [file, codes] of Object.entries(baseline)) {
for (const [code, baselineCount] of Object.entries(codes)) {
const liveCount = (live[file] && live[file][code]) || 0;
if (liveCount === 0 && baselineCount > 0) {
improvements.push({ file, code, liveCount: 0, baselineCount });
}
}
}
return { regressions, improvements };
}
function runTsc() {
try {
const stdout = execFileSync(
process.platform === "win32" ? "npx.cmd" : "npx",
["tsc", "--pretty", "false", "--noEmit", "-p", TSCONFIG],
{ encoding: "utf8", maxBuffer: 64 * 1024 * 1024, cwd: ROOT }
);
return stdout;
} catch (err) {
// tsc exits non-zero when there are type errors — stdout still has the report.
if (err.stdout) return String(err.stdout);
throw err;
}
}
function loadBaseline() {
if (!fs.existsSync(BASELINE_PATH)) return {};
return JSON.parse(fs.readFileSync(BASELINE_PATH, "utf8"));
}
function writeBaseline(counts) {
fs.writeFileSync(BASELINE_PATH, JSON.stringify(counts, null, 2) + "\n");
}
function main() {
if (!fs.existsSync(TSCONFIG)) {
process.stderr.write(`[dashboard-typecheck] FAIL — tsconfig not found at ${TSCONFIG}\n`);
process.exit(2);
}
console.log("[dashboard-typecheck] Running tsc scoped to src/app/(dashboard)/**…");
const stdout = runTsc();
const live = parseTscOutput(stdout);
const baseline = loadBaseline();
const { regressions, improvements } = diffAgainstBaseline(live, baseline);
const liveErrorCount = Object.values(live).reduce(
(sum, codes) => sum + Object.values(codes).reduce((s, c) => s + c, 0),
0
);
console.log(`dashboardTypecheckErrors=${liveErrorCount}`);
if (UPDATE) {
writeBaseline(live);
console.log(`[dashboard-typecheck] baseline rewritten (${liveErrorCount} errors frozen).`);
process.exit(0);
}
if (improvements.length > 0) {
console.log(
`[dashboard-typecheck] ${improvements.length} baselined error(s) no longer present ` +
`— run 'node scripts/check/check-dashboard-typecheck.mjs --update' to ratchet the baseline down:\n` +
improvements
.map((i) => ` - ${i.file} ${i.code} (baseline ${i.baselineCount} -> live ${i.liveCount})`)
.join("\n")
);
}
if (regressions.length > 0) {
process.stderr.write(
`[dashboard-typecheck] FAIL — ${regressions.length} new/regressed TypeScript error(s) ` +
`under src/app/(dashboard)/ not covered by the frozen baseline:\n` +
regressions
.map((r) => `${r.file} ${r.code} (baseline ${r.baselineCount}, live ${r.liveCount})`)
.join("\n") +
`\n\nIf this is a genuine new dashboard TSX bug (e.g. an orphaned identifier), fix it.\n` +
`If it's pre-existing type looseness you're intentionally not fixing in this PR,\n` +
`do NOT widen the baseline for new regressions — that defeats the gate.\n`
);
process.exit(1);
}
console.log(
`[dashboard-typecheck] OK — ${liveErrorCount} pre-existing error(s), all within frozen baseline.`
);
process.exit(0);
}
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) {
main();
}

View File

@@ -101,6 +101,15 @@ const IGNORE_FROM_CODE = new Set([
// ("http://192.168.0.15:20128" / null), never OmniRoute runtime config (#5151).
"COMBO_LIVE_BASE_URL",
"COMBO_LIVE_API_KEY",
// Homologation E2E suite (npm run homolog) vars — configured via the dedicated
// .env.homolog file (template: .env.homolog.example), never in the runtime .env.
// Test/ops-only signals against the homologation VPS, same class as COMBO_LIVE_*.
// See docs/ops/HOMOLOGATION.md.
"HOMOLOG_BASE_URL",
"HOMOLOG_ADMIN_PASSWORD",
"HOMOLOG_API_KEY",
"HOMOLOG_CRITICAL_PROVIDERS",
"HOMOLOG_EXPECT_VERSION",
// update-notifier opt-out for the CLI binary.
"OMNIROUTE_NO_UPDATE_NOTIFIER",
// Headless CLI execution flag for Electron.
@@ -132,6 +141,12 @@ const IGNORE_FROM_CODE = new Set([
"QA_LOCALES",
"QA_REPORT_SUFFIX",
"QA_ROUTES",
// Post-publish verifier (scripts/release/verify-published.mjs): env passed INTO the
// clean Docker container script (Hard Rule #13 env-option pattern) — release tooling
// internals, never OmniRoute runtime config.
"VERIFY_DEADLINE_S",
"VERIFY_PORT",
"VERIFY_VERSION",
// Doctor diagnostic flags (no runtime behavior yet — placeholders).
"OMNIROUTE_DOCTOR_HOST",
"OMNIROUTE_DOCTOR_LIVENESS_URL",

View File

@@ -0,0 +1,166 @@
#!/usr/bin/env node
/**
* check:pack-boot — boot-smoke of the REAL npm tarball (#7065 class killer, WS1.2/T1).
*
* Three releases shipped a tarball that crashed on every boot (tls-options/3.8.41,
* head-response-guard VPS #7040 + npm #7065) because no gate ever EXECUTED the
* artifact: structure checks (check:pack-artifact) validate lists, not runtime.
* This gate packs the tree, installs the tarball into a clean prefix, boots the
* installed CLI and polls /api/monitoring/health until it proves the artifact
* starts — regardless of WHICH packaging list drifted.
*
* Requires a built dist/ (run after `npm run build:cli`, e.g. in the CI
* package-artifact job or `check:release-green --with-build`). Exit codes:
* 0 = boots and reports the right version · 1 = boot failed · 2 = missing build.
*/
import { execFileSync, spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const POLL_INTERVAL_MS = 2_000;
const BOOT_DEADLINE_MS = 240_000;
/** Parse `npm pack --json` output into the generated tarball filename. */
export function pickTarball(packJsonOutput) {
const parsed = JSON.parse(packJsonOutput);
const filename = Array.isArray(parsed) ? parsed[0]?.filename : undefined;
if (!filename) throw new Error("npm pack --json returned no filename");
// npm >=9 may emit scoped names with "/" — normalize to the on-disk file name.
return filename.replace(/\//g, "-");
}
/**
* Boot verdict: HTTP 200 + a JSON body reporting the version we just packed.
* `status` is logged but NOT asserted — a clean install with zero providers may
* legitimately report degraded states; the gate targets boot crashes, not health.
*/
export function evaluateBoot(httpStatus, body, expectedVersion) {
const failures = [];
if (httpStatus !== 200) failures.push(`health HTTP ${httpStatus} (expected 200)`);
if (!body || typeof body !== "object") failures.push("health body is not JSON");
else if (body.version !== expectedVersion)
failures.push(`version "${body.version}" (expected "${expectedVersion}")`);
return { ok: failures.length === 0, failures };
}
/** Deterministic-enough free-ish port in a range CI runners don't use. */
export function pickPort(seed = process.pid) {
return 23000 + (seed % 4000);
}
function log(msg) {
console.log(`[pack-boot] ${msg}`);
}
async function main() {
const ROOT = process.cwd();
if (!fs.existsSync(path.join(ROOT, "dist", "server.js"))) {
console.error("[pack-boot] dist/server.js missing — run `npm run build:cli` first (this is a --with-build gate)");
process.exit(2);
}
const expectedVersion = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-pack-boot-"));
let child = null;
let exitCode = 1;
try {
log(`packing v${expectedVersion}`);
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
cwd: ROOT,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const tarball = path.join(tmp, pickTarball(packOut));
log(`installing ${path.basename(tarball)} into a clean prefix (postinstall runs for real)…`);
const prefix = path.join(tmp, "prefix");
execFileSync("npm", ["install", "-g", "--prefix", prefix, tarball], {
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
const port = pickPort();
const dataDir = path.join(tmp, "data");
fs.mkdirSync(dataDir, { recursive: true });
const binPath = path.join(prefix, "bin", "omniroute");
log(`booting installed CLI on :${port} (DATA_DIR isolated)…`);
child = spawn(binPath, ["serve", "--port", String(port)], {
env: {
...process.env,
PORT: String(port),
DATA_DIR: dataDir,
JWT_SECRET: "pack-boot-smoke-secret-with-sufficient-length-000",
API_KEY_SECRET: "pack-boot-smoke-api-key-secret-long",
DISABLE_SQLITE_AUTO_BACKUP: "true",
OMNIROUTE_SKIP_SYSTEM_TRUST: "1",
},
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
const tail = [];
const keepTail = (chunk) => {
tail.push(String(chunk));
while (tail.length > 80) tail.shift();
};
child.stdout.on("data", keepTail);
child.stderr.on("data", keepTail);
let childExit = null;
child.on("exit", (code) => {
childExit = code ?? -1;
});
const deadline = Date.now() + BOOT_DEADLINE_MS;
let verdict = { ok: false, failures: ["never polled"] };
while (Date.now() < deadline) {
if (childExit !== null) {
verdict = { ok: false, failures: [`process exited with code ${childExit} before serving`] };
break;
}
try {
const res = await fetch(`http://127.0.0.1:${port}/api/monitoring/health`);
const body = await res.json().catch(() => null);
verdict = evaluateBoot(res.status, body, expectedVersion);
if (verdict.ok) {
log(`healthy: HTTP 200, version ${body.version}, status "${body.status}"`);
break;
}
} catch {
// not listening yet — keep polling
}
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
}
if (verdict.ok) {
log("✅ the packed tarball boots — #7065 class gate green");
exitCode = 0;
} else {
console.error(`[pack-boot] ❌ boot FAILED: ${verdict.failures.join("; ")}`);
console.error("[pack-boot] last server output:\n" + tail.join("").split("\n").slice(-40).join("\n"));
exitCode = 1;
}
} finally {
if (child?.pid) {
try {
process.kill(-child.pid, "SIGTERM");
} catch {
/* already gone */
}
await new Promise((r) => setTimeout(r, 2_000));
try {
process.kill(-child.pid, "SIGKILL");
} catch {
/* already gone */
}
}
fs.rmSync(tmp, { recursive: true, force: true });
}
process.exit(exitCode);
}
const isDirectRun =
process.argv[1] && path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isDirectRun) {
main().catch((e) => {
console.error("[pack-boot] fatal:", e.message);
process.exit(1);
});
}

View File

@@ -127,6 +127,13 @@ export const COLLECTORS = [
glob: "tests/e2e/protocol-clients.test.ts",
sources: ["scripts/dev/run-protocol-clients-tests.mjs"],
},
// Playwright — suíte de homologação real (npm run homolog, L4 UI): run.mjs invoca
// `playwright test -c tests/homolog/ui/playwright.config.ts` (testMatch **/*.spec.ts).
{
glob: "tests/homolog/ui/*.spec.ts",
sources: ["scripts/homolog/run.mjs"],
anchors: { "scripts/homolog/run.mjs": "tests/homolog/ui/playwright.config.ts" },
},
];
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

View File

@@ -14,6 +14,7 @@ import headResponseGuard from "./head-response-guard.cjs";
import { ensureNativeSqlite } from "./ensure-native-sqlite.mjs";
import { isTurbopackCacheCorruption, purgeAllTurbopackCaches } from "./turbopackCacheHeal.mjs";
import { randomUUID } from "node:crypto";
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
const { maybeHandleDisallowedMethod } = methodGuard;
const { wrapRequestListenerWithHeadResponseGuard } = headResponseGuard;
@@ -153,6 +154,15 @@ async function start() {
return requestHandler(req, res);
})
);
// Node's http.Server default keepAliveTimeout (5_000ms) races pooled
// keep-alive HTTP clients that idle longer than that between requests (e.g.
// the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which
// reuse a socket the server already tore down and get 0 response bytes back
// (#7003). Raise both timeouts well above any realistic client idle-pool
// window, mirroring src/lib/apiBridgeServer.ts's pattern.
const mainServerTimeouts = getMainServerTimeoutConfig();
server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs;
server.headersTimeout = mainServerTimeouts.headersTimeoutMs;
server.on("upgrade", async (req, socket, head) => {
try {
const responsesWsHandled = await responsesWsProxy.handleUpgrade(req, socket, head);

View File

@@ -7,6 +7,7 @@ import { maybeHandleWebdav } from "./webdav-handler.mjs";
import methodGuard from "./http-method-guard.cjs";
import headResponseGuard from "./head-response-guard.cjs";
import { resolveTlsOptions, createServerListener } from "./tls-options.mjs";
import { getMainServerTimeoutConfig } from "../../src/shared/utils/runtimeTimeouts.ts";
const originalCreateServer = http.createServer.bind(http);
const proxiesByPort = new Map();
@@ -151,6 +152,19 @@ http.createServer = function createServerWithResponsesWs(...args) {
// listener); otherwise the original http.Server. The downstream .on/.addListener
// patches below apply identically to both (https.Server extends http.Server).
const server = createServerListener(args, tlsOptions, { createHttp: originalCreateServer });
// Node's http.Server default keepAliveTimeout (5_000ms) races pooled
// keep-alive HTTP clients that idle longer than that between requests (e.g.
// the JVM java.net.http.HttpClient used by JetBrains AI Assistant), which
// reuse a socket the server already tore down and get 0 response bytes back
// (#7003). This wrapper is what `omniroute serve` / Docker / Electron actually
// spawn in production (run-standalone.mjs prefers server-ws.mjs over the bare
// Next server.js), so it needs the same fix already wired into run-next.mjs
// (the dev-only entry point) — otherwise real installs never got it. Raise
// both timeouts well above any realistic client idle-pool window, mirroring
// src/lib/apiBridgeServer.ts's pattern.
const mainServerTimeouts = getMainServerTimeoutConfig();
server.keepAliveTimeout = mainServerTimeouts.keepAliveTimeoutMs;
server.headersTimeout = mainServerTimeouts.headersTimeoutMs;
const originalOn = server.on.bind(server);
const originalAddListener = server.addListener.bind(server);

View File

@@ -0,0 +1,52 @@
import fs from "node:fs";
import path from "node:path";
import { pickSmokeModels } from "./lib/providerTiers.mjs";
const baseUrl = process.env.HOMOLOG_BASE_URL;
const critical = (process.env.HOMOLOG_CRITICAL_PROVIDERS || "").split(",").filter(Boolean);
const res = await fetch(`${baseUrl}/v1/models`, {
headers: { Authorization: `Bearer ${process.env.HOMOLOG_API_KEY}` },
});
if (!res.ok) throw new Error(`/v1/models HTTP ${res.status}`);
const catalog = (await res.json()).data;
const picks = pickSmokeModels(catalog, critical);
const missing = picks.filter((p) => !p.model);
const providers = picks
.filter((p) => p.model)
.map((p) => ({
id: `openai:chat:${p.model}`,
label: p.provider,
config: {
apiBaseUrl: `${baseUrl}/v1`,
apiKeyEnvar: "HOMOLOG_API_KEY",
max_tokens: 5,
temperature: 0,
// OmniRoute streama por default quando "stream" é omitido (streamDefaultMode
// legacy) — o parser JSON do promptfoo precisa da resposta non-stream.
passthrough: { stream: false, max_tokens: 5 },
},
}));
const config = {
description: "OmniRoute homolog — smoke real 1 request/provider crítico",
prompts: ["Reply with exactly: OK"],
providers,
// O smoke valida o WIRING do provider (respondeu sem erro), não o comportamento
// do modelo: com max_tokens=5, modelos de reasoning podem gastar o budget antes
// de emitir o "OK" literal — icontains seria falso-positivo de quebra.
tests: [{ assert: [{ type: "javascript", value: "typeof output === 'string'" }] }],
};
fs.mkdirSync("homolog-report/raw", { recursive: true });
fs.writeFileSync(
path.join("homolog-report", "promptfooconfig.yaml"),
JSON.stringify(config, null, 2) // promptfoo aceita JSON como config YAML-compatível
);
fs.writeFileSync(
path.join("homolog-report", "raw", "provider-misses.json"),
JSON.stringify(missing, null, 2)
);
console.log(
`[gen-promptfoo] ${providers.length} providers no smoke, ${missing.length} misses de catálogo`
);

View File

@@ -0,0 +1,65 @@
// Cookie confirmado em src/app/api/auth/login/route.ts (cookieStore.set("auth_token", ...))
// e em src/shared/utils/apiAuth.ts (isDashboardSessionAuthenticated lê "auth_token").
const TOKEN_COOKIE = "auth_token";
export function extractJwtCookie(setCookies) {
for (const c of setCookies || []) {
const m = c.match(new RegExp(`^(${TOKEN_COOKIE}=[^;]+)`));
if (m) return m[1];
}
return null;
}
export function extractApiKey(body) {
if (!body?.key || !body?.id) throw new Error("POST /api/keys sem key/id no corpo");
return { key: body.key, id: body.id };
}
// fetch com 1 retry para erros de socket (keep-alive reciclado pelo servidor
// entre requests espaçados derruba o 1º write com EPIPE/other side closed).
async function fetchRetry(url, init, retries = 1) {
try {
return await fetch(url, init);
} catch (err) {
if (retries > 0) {
await new Promise((r) => setTimeout(r, 1_000));
return fetchRetry(url, init, retries - 1);
}
throw err;
}
}
/** Login admin → cria API key efêmera. Retorna {key, id, cookie, revoke()}. */
export async function createEphemeralKey(baseUrl, password) {
const login = await fetchRetry(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (!login.ok) throw new Error(`login falhou: HTTP ${login.status}`);
const cookie = extractJwtCookie(login.headers.getSetCookie());
if (!cookie) throw new Error("login sem cookie de sessão");
// sufixo único por run: dois runs paralelos (ou um cleanup por nome) nunca colidem
const name = `homolog-${new Date().toISOString().slice(0, 10)}-${Math.random().toString(36).slice(2, 8)}`;
const create = await fetchRetry(`${baseUrl}/api/keys`, {
method: "POST",
headers: { "Content-Type": "application/json", cookie },
body: JSON.stringify({ name }),
});
if (!create.ok) throw new Error(`criação de key falhou: HTTP ${create.status}`);
const { key, id } = extractApiKey(await create.json());
return {
key,
id,
cookie,
async revoke() {
const del = await fetchRetry(`${baseUrl}/api/keys/${id}`, {
method: "DELETE",
headers: { cookie },
});
if (!del.ok) throw new Error(`revogação da key ${id} falhou: HTTP ${del.status}`);
},
};
}

View File

@@ -0,0 +1,15 @@
/**
* Avaliação pura de paridade do deploy (testável sem rede).
* @param {{status?: string, version?: string}} health corpo de /api/monitoring/health
* @param {{expectedVersion: string, httpStatus: number}} ctx
* @returns {{ok: boolean, failures: string[]}}
*/
export function evaluateParity(health, ctx) {
const failures = [];
if (ctx.httpStatus !== 200) failures.push(`health HTTP ${ctx.httpStatus} (esperado 200)`);
if (health?.status !== "healthy")
failures.push(`status "${health?.status}" (esperado "healthy")`);
if (health?.version !== ctx.expectedVersion)
failures.push(`version "${health?.version}" (esperado "${ctx.expectedVersion}")`);
return { ok: failures.length === 0, failures };
}

View File

@@ -0,0 +1,24 @@
export function promptfooToCtrf(output) {
const rows = output?.results?.results || [];
const tests = rows.map((r) => ({
name: `provider-smoke: ${r.provider?.label || r.provider?.id || "?"}`,
status: r.success ? "passed" : "failed",
duration: Math.round(r.latencyMs || 0),
...(r.error ? { message: String(r.error).slice(0, 300) } : {}),
}));
const passed = tests.filter((t) => t.status === "passed").length;
return {
results: {
tool: { name: "promptfoo" },
summary: {
tests: tests.length,
passed,
failed: tests.length - passed,
pending: 0,
skipped: 0,
other: 0,
},
tests,
},
};
}

View File

@@ -0,0 +1,7 @@
/** Escolhe 1 modelo por provider crítico a partir do catálogo /v1/models. */
export function pickSmokeModels(catalog, criticalProviders) {
return criticalProviders.map((provider) => {
const hit = catalog.find((m) => m.id.startsWith(`${provider}/`));
return { provider, model: hit ? hit.id : null };
});
}

View File

@@ -0,0 +1,80 @@
export function parseSseChunk(text) {
// Itera LINHAS dentro de cada bloco: a VPS emite comment-lines SSE
// (": x-omniroute-*") no mesmo bloco do "data: [DONE]", então olhar só o
// início do bloco perde o terminador.
const events = [];
for (const block of text.split(/\n\n/)) {
for (const line of block.split("\n")) {
const t = line.trim();
if (t.startsWith("data:")) events.push(t.slice(5).trim());
}
}
return events;
}
export function summarizeStream(events) {
let contentDeltas = 0;
let done = false;
for (const e of events) {
if (e === "[DONE]") {
done = true;
continue;
}
try {
const j = JSON.parse(e);
if (j.choices?.[0]?.delta?.content) contentDeltas++;
} catch {
/* fragmento parcial — ignorado; o caller acumula buffer */
}
}
const ok = contentDeltas >= 1 && done;
return { ok, contentDeltas, done };
}
/** Faz 1 chat streaming real e valida o protocolo SSE ponta-a-ponta. */
export async function checkSse(baseUrl, apiKey, model, { retries = 1 } = {}) {
try {
const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
body: JSON.stringify({
model,
messages: [{ role: "user", content: "Reply with exactly: OK" }],
max_tokens: 5,
stream: true,
}),
});
if (res.status !== 200) return { ok: false, failures: [`HTTP ${res.status}`] };
const ct = res.headers.get("content-type") || "";
if (!ct.includes("text/event-stream")) return { ok: false, failures: [`content-type "${ct}"`] };
const events = [];
let buffer = "";
const reader = res.body.getReader();
const decoder = new TextDecoder();
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lastSep = buffer.lastIndexOf("\n\n");
if (lastSep >= 0) {
events.push(...parseSseChunk(buffer.slice(0, lastSep + 2)));
buffer = buffer.slice(lastSep + 2);
}
}
// flush do resto do buffer (último bloco pode chegar sem "\n\n" no read final)
if (buffer.trim()) events.push(...parseSseChunk(buffer));
const s = summarizeStream(events);
return { ok: s.ok, failures: s.ok ? [] : [`contentDeltas=${s.contentDeltas} done=${s.done}`] };
} catch (err) {
// Socket keep-alive reciclado pelo servidor entre requests é transitório —
// 1 retry antes de reportar falha. Erro persistente é FALHA da camada,
// nunca crash do orquestrador.
if (retries > 0) {
await new Promise((r) => setTimeout(r, 1_000));
return checkSse(baseUrl, apiKey, model, { retries: retries - 1 });
}
return { ok: false, failures: [`fetch/stream error: ${err?.cause?.message || err.message}`] };
}
}

169
scripts/homolog/run.mjs Normal file
View File

@@ -0,0 +1,169 @@
#!/usr/bin/env node
import { execSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import { evaluateParity } from "./lib/parity.mjs";
import { createEphemeralKey } from "./lib/adminClient.mjs";
import { checkSse } from "./lib/sseCheck.mjs";
import { promptfooToCtrf } from "./lib/promptfooToCtrf.mjs";
// ── env ──────────────────────────────────────────────────────────────────
if (fs.existsSync(".env.homolog")) {
for (const line of fs.readFileSync(".env.homolog", "utf8").split("\n")) {
const m = line.match(/^([A-Z_]+)=(.*)$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2];
}
}
const BASE = process.env.HOMOLOG_BASE_URL;
if (!BASE || !process.env.HOMOLOG_ADMIN_PASSWORD) {
console.error("Configure .env.homolog (HOMOLOG_BASE_URL, HOMOLOG_ADMIN_PASSWORD)");
process.exit(2);
}
fs.rmSync("homolog-report", { recursive: true, force: true });
// raw/ fica FORA do merge CTRF: `ctrf merge` tenta mesclar qualquer *.json com
// chave "results" e quebra no output cru do promptfoo.
fs.mkdirSync("homolog-report/raw", { recursive: true });
const layers = []; // {name, ok, detail}
const record = (name, ok, detail = "") => {
layers.push({ name, ok, detail });
console.log(`${ok ? "✅" : "❌"} ${name}${detail ? `${detail}` : ""}`);
};
// ── L0 saúde/paridade ────────────────────────────────────────────────────
const expectedVersion =
process.env.HOMOLOG_EXPECT_VERSION || JSON.parse(fs.readFileSync("package.json", "utf8")).version;
const healthRes = await fetch(`${BASE}/api/monitoring/health`);
const health = await healthRes.json().catch(() => ({}));
const parity = evaluateParity(health, { expectedVersion, httpStatus: healthRes.status });
record("L0 saúde/paridade", parity.ok, parity.failures.join("; "));
if (!parity.ok) {
console.error("Deploy divergente — abortando.");
writeSummary(layers, BASE, expectedVersion);
process.exit(1);
}
// ── chave efêmera ────────────────────────────────────────────────────────
const eph = await createEphemeralKey(BASE, process.env.HOMOLOG_ADMIN_PASSWORD);
process.env.HOMOLOG_API_KEY = eph.key;
try {
// modelo de smoke = 1º do tier crítico presente no catálogo
const models = (
await (
await fetch(`${BASE}/v1/models`, { headers: { Authorization: `Bearer ${eph.key}` } })
).json()
).data;
const critical = (process.env.HOMOLOG_CRITICAL_PROVIDERS || "openai").split(",");
const smokeModel =
models.find((m) => critical.some((p) => m.id.startsWith(`${p}/`)))?.id || models[0].id;
// ── L1 httpYac + SSE ───────────────────────────────────────────────────
const hy = spawnSync(
"npx",
[
"httpyac",
"send",
"tests/homolog/api/core.http",
"--all",
"--var",
`baseUrl=${BASE}`,
"--var",
`apiKey=${eph.key}`,
"--var",
`smokeModel=${smokeModel}`,
"--junit",
"--output",
"none",
],
{ encoding: "utf8" }
);
fs.writeFileSync("homolog-report/httpyac-junit.xml", hy.stdout || "");
record("L1 API (httpYac)", hy.status === 0);
const sse = await checkSse(BASE, eph.key, smokeModel);
record("L1 SSE streaming", sse.ok, (sse.failures || []).join("; "));
// ── L2 providers reais ─────────────────────────────────────────────────
try {
execSync("node scripts/homolog/gen-promptfoo.mjs", { stdio: "inherit", env: process.env });
spawnSync(
"npx",
[
"promptfoo",
"eval",
"-c",
"homolog-report/promptfooconfig.yaml",
"-o",
"homolog-report/raw/promptfoo.json",
"--no-cache",
],
{ encoding: "utf8", env: process.env }
);
const pfOut = JSON.parse(fs.readFileSync("homolog-report/raw/promptfoo.json", "utf8"));
const pfCtrf = promptfooToCtrf(pfOut);
fs.writeFileSync("homolog-report/providers-ctrf.json", JSON.stringify(pfCtrf, null, 2));
record(
"L2 providers reais",
pfCtrf.results.summary.failed === 0,
`${pfCtrf.results.summary.passed}/${pfCtrf.results.summary.tests} providers OK`
);
} catch (err) {
// gerador/eval quebrando é falha da camada — o run continua para o L4 e o cleanup
record("L2 providers reais", false, err.message);
}
// ── L4 UI ──────────────────────────────────────────────────────────────
const pw = spawnSync(
"npx",
["playwright", "test", "-c", "tests/homolog/ui/playwright.config.ts"],
{
stdio: "inherit",
env: process.env,
}
);
record("L4 UI (Playwright)", pw.status === 0);
} finally {
await eph
.revoke()
.then(() => record("cleanup: key efêmera revogada", true))
.catch((e) => record("cleanup: key efêmera revogada", false, e.message));
}
// ── L5 relatório unificado ───────────────────────────────────────────────
spawnSync(
"npx",
["junit-to-ctrf", "homolog-report/httpyac-junit.xml", "-o", "homolog-report/api-ctrf.json"],
{
stdio: "inherit",
}
);
spawnSync(
"npx",
[
"ctrf",
"merge",
"homolog-report",
"--output",
"homolog-ctrf.json",
"--output-dir",
"homolog-report",
],
{
stdio: "inherit",
}
);
writeSummary(layers, BASE, expectedVersion);
const failed = layers.filter((l) => !l.ok);
process.exit(failed.length ? 1 : 0);
function writeSummary(rows, base, version) {
const md = [
"# Homologação — relatório",
"",
`Alvo: ${base} · versão esperada: ${version}`,
"",
"| camada | resultado | detalhe |",
"|---|---|---|",
...rows.map((l) => `| ${l.name} | ${l.ok ? "✅" : "❌"} | ${l.detail} |`),
].join("\n");
fs.writeFileSync("homolog-report/summary.md", md);
console.log(`\n${md}\n\nRelatório: homolog-report/ (CTRF unificado: homolog-ctrf.json)`);
}

53
scripts/ops/runner-janitor.sh Executable file
View File

@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# runner-janitor — self-hosted runner box hygiene (WS3.3, v3.8.49 quality plan).
#
# The .113 runner box has recurring failure modes that until now were manual
# discipline: orphaned tmpfs/work dirs filling the disk, and >4 concurrent
# runners OOM-killing jobs (16 GB box; incidents on the v3.8.47 release day).
# Install via cron on the box (see docs/ops/RUNNER_BOX.md):
# */30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
#
# Exit codes: 0 healthy · 1 attention needed (printed to stdout for the log).
set -euo pipefail
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-4}"
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
WORK_DIR_MAX_AGE_HOURS="${WORK_DIR_MAX_AGE_HOURS:-24}"
STATUS=0
echo "[janitor] $(date -u +%FT%TZ) start"
# 1) Sweep stale runner temp/work leftovers (>24h — no legitimate job runs that long).
# Hardened for a root cron on world-writable paths: never follow a symlinked base
# (a compromised runner could plant one), -P + -xdev so the sweep cannot traverse
# out of the filesystem, and patterns narrowed to names OUR tooling creates
# (no generic tmp* — unrelated system temp files are out of scope).
for base in /tmp /home/*/actions-runner*/_work/_temp; do
[ -d "$base" ] || continue
[ -L "$base" ] && { echo "[janitor] skip symlinked base: $base"; continue; }
find -P "$base" -xdev -maxdepth 1 \( -name 'runner-*' -o -name 'omniroute-*' \) \
! -type l -mmin +$((WORK_DIR_MAX_AGE_HOURS * 60)) -exec rm -rf {} + 2>/dev/null || true
done
echo "[janitor] stale temp sweep done"
# 2) Disk pressure — alert loudly before SQLITE_FULL kills jobs mid-run.
USAGE=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$USAGE" -ge "$DISK_ALERT_PCT" ]; then
echo "[janitor] ⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"
STATUS=1
else
echo "[janitor] disk ${USAGE}% OK"
fi
# 3) Concurrency ceiling — 8-wide OOMed the 16 GB box twice on release day;
# 4 is the proven ceiling. This CODIFIES the rule that was manual discipline.
ACTIVE=$(pgrep -fc "Runner.Listener" || true)
if [ "${ACTIVE:-0}" -gt "$MAX_ACTIVE_RUNNERS" ]; then
echo "[janitor] ⚠ ${ACTIVE} Runner.Listener processes > ceiling ${MAX_ACTIVE_RUNNERS} — stop the extra runners (systemctl stop actions.runner.<name>)"
STATUS=1
else
echo "[janitor] runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} OK"
fi
echo "[janitor] done status=$STATUS"
exit "$STATUS"

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* balance-e2e-shards — duration-aware LPT bin-packing for the Playwright matrix (WS4.1).
*
* Playwright's --shard=N/M distributes by COUNT (per file with fullyParallel:false),
* blind to duration — measured skew on the 9-shard matrix: worst 24m47s vs best 1m47s
* (14×), putting E2E on the CI critical path. This script assigns spec FILES to shards
* by weight (Longest Processing Time greedy: heaviest first, always into the lightest
* shard) using config/quality/e2e-timings.json, and prints shard N's files (one per
* line) for `npx playwright test $FILES`.
*
* Safety: the union of all shards is verified to equal the discovered spec list —
* losing a spec silently would hollow the suite. Any inconsistency (or a missing
* timings file) exits non-zero so the CI step falls back to plain --shard=N/M.
*
* Weights are RELATIVE (unitless). The seed uses LOC as a proxy; regenerate from real
* durations by editing config/quality/e2e-timings.json (see its _meta note).
*/
import fs from "node:fs";
import path from "node:path";
const E2E_DIR = path.join("tests", "e2e");
const TIMINGS_PATH = path.join("config", "quality", "e2e-timings.json");
/**
* LPT greedy assignment. Deterministic: weight desc, then filename asc; ties on
* shard totals resolve to the lowest shard index.
* @param {{file: string, weight: number}[]} items
* @param {number} shardCount
* @returns {{files: string[], total: number}[]}
*/
export function lptAssign(items, shardCount) {
const shards = Array.from({ length: shardCount }, () => ({ files: [], total: 0 }));
const sorted = [...items].sort(
(a, b) => b.weight - a.weight || a.file.localeCompare(b.file)
);
for (const item of sorted) {
let target = shards[0];
for (const s of shards) if (s.total < target.total) target = s;
target.files.push(item.file);
target.total += item.weight;
}
return shards;
}
/**
* Weight lookup with a median fallback so a NEW spec (no timing yet) lands mid-pack
* instead of skewing a shard.
* @param {string[]} files basenames
* @param {Record<string, number>} timings
*/
export function weightItems(files, timings) {
const known = Object.entries(timings)
.filter(([k]) => !k.startsWith("_"))
.map(([, v]) => v)
.filter((v) => Number.isFinite(v) && v > 0)
.sort((a, b) => a - b);
const median = known.length ? known[Math.floor(known.length / 2)] : 1;
return files.map((file) => ({
file,
weight: Number.isFinite(timings[file]) && timings[file] > 0 ? timings[file] : median,
}));
}
function main() {
const shard = Number(process.argv[2]);
const total = Number(process.argv[3]);
if (!Number.isInteger(shard) || !Number.isInteger(total) || shard < 1 || shard > total) {
console.error("usage: node scripts/quality/balance-e2e-shards.mjs <shard> <totalShards>");
process.exit(2);
}
if (!fs.existsSync(TIMINGS_PATH)) {
console.error(`[e2e-balance] ${TIMINGS_PATH} missing — caller should fall back to --shard`);
process.exit(3);
}
const timings = JSON.parse(fs.readFileSync(TIMINGS_PATH, "utf8"));
const files = fs
.readdirSync(E2E_DIR)
.filter((f) => f.endsWith(".spec.ts"))
.sort();
if (!files.length) {
console.error(`[e2e-balance] no specs found under ${E2E_DIR}`);
process.exit(3);
}
const shards = lptAssign(weightItems(files, timings), total);
const assigned = shards.flatMap((s) => s.files).sort();
if (assigned.length !== files.length || assigned.some((f, i) => f !== files[i])) {
console.error("[e2e-balance] INTERNAL: shard union != discovered specs — falling back");
process.exit(3);
}
process.stdout.write(
shards[shard - 1].files.map((f) => path.join(E2E_DIR, f)).join("\n") + "\n"
);
}
const isDirectRun =
process.argv[1] &&
path.resolve(process.argv[1]) === path.resolve(new URL(import.meta.url).pathname);
if (isDirectRun) main();

Some files were not shown because too many files have changed in this diff Show More