Compare commits

..

4 Commits

Author SHA1 Message Date
diegosouzapw
8506966d7e docs(i18n): re-sync the 42 llm.txt mirrors after the migration-count bump 2026-08-27 17:25:11 -03:00
diegosouzapw
b95fedfc2a docs: bump the migration count to 160 after 163_model_capabilities
check:docs-counts-sync enforces the shipped migration count as a STRICT claim in README.md,
AGENTS.md and llm.txt.
2026-08-27 17:24:39 -03:00
diegosouzapw
eafca19b37 docs(db): record the real cause of the cache_metrics residual in the allowlist
The allowlist described every residual as "a CREATE that left the migration set in some
past cycle". cache_metrics never was in the migration set: it is created lazily by
ensureCacheMetricsTable() (src/lib/semanticCache.ts:34) the first time the semantic cache
runs, which is the same class as the model_capabilities divergence that blocked the v3.8.50
publish. Document both causes so the next residual is fixed with a migration where that is
the right answer, instead of reflexively allowlisted.
2026-08-27 17:17:37 -03:00
diegosouzapw
6a23c1b645 fix(db): converge the install and upgrade schemas; stop ENOSPC from faking a divergence
The v3.8.50 publish run failed `check:install-upgrade` with "15 tables a CLEAN install
creates but an UPGRADE does not" (agentic_conversations, ccr_blocks, the whole Radar set,
jobs/job_runs, exclusive_connection_leases, …). None of them was missing.

Root cause, from the CI log (run 33104507735): the Phase B upgrade `npm install` hit
`npm warn tar TAR_ENTRY_ERROR ENOSPC: no space left on device` 5611 times, npm still exited
0, and the resulting truncated package made `omniroute serve` "exit with code 0 before
serving". No migration ever ran, so the database still held the 3.8.49 schema (115 tables)
and every post-133 migration table read as a divergence.

Verified against the real thing: booting the published omniroute@3.8.49 and replaying that
database through the current runner applies exactly 29 migrations and lands on the same
table set a clean install produces — the migration set was never at fault.

What changes:

- `163_model_capabilities.sql` — the one genuine convergence defect. The table was only
  ever created by `ensureCapabilitiesTable()` on the first models.dev sync, so whether a
  database has it depends on timing, not on the schema version. It is the residual the
  gate reported. A migration makes both install paths deterministic.
- `check:install-upgrade` now fails on an ENOSPC-truncated install instead of measuring a
  broken tree; authenticates its health probe with a minted internal-service token, so the
  version assertion works against the health payload hardened by GHSA-mvf8-qc78-5mxm
  (an anonymous caller gets no version — the same run also failed with "health reports
  version undefined"); frees the ~3 GB clean-install tree before the upgrade phase; warns
  when the temp filesystem cannot hold the run; prints the failing server's output; and
  skips the convergence verdict when a phase never served, so a broken boot can no longer
  manufacture a schema divergence on top of the real failure.

Tests: `tests/unit/db-install-upgrade-schema-parity.test.ts` pins the deterministic half of
the gate in milliseconds (every migration reachable on a clean install; model_capabilities
comes from the migration set; its DDL does not drift from the runtime helper), and the
ENOSPC guard is covered in the existing gate test.
2026-08-27 17:16:14 -03:00
1309 changed files with 2415 additions and 4316 deletions

View File

@@ -1023,13 +1023,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# to disable the check. Used by: src/lib/db/migrationRunner.ts. Default: 50.
#OMNIROUTE_MAX_PENDING_MIGRATIONS=50
# Working directory for the check:install-upgrade release gate. It builds two ~3 GB
# install trees plus a ~275 MB tarball, so it needs roughly 12 GB — more than the
# 12 GB RAM-backed tmpfs that /tmp is on the self-hosted runner, where it exhausted
# the tmpfs and npm silently truncated the package. Defaults to <repo>/.install-upgrade
# on real disk. Used by: scripts/check/check-install-upgrade.mjs. Default: <repo>/.install-upgrade.
#OMNIROUTE_INSTALL_UPGRADE_WORKDIR=/var/tmp/omniroute-install-upgrade
# Trust user-managed RTK project filter rules without strict signature checks.
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0

View File

@@ -606,24 +606,13 @@ jobs:
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
# omni-build) instead of queueing on the 20-concurrent-job hosted pool.
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
# back to ubuntu-latest unless the PR head repo is this repository (push /
# dispatch events are own-origin by definition). Any failure path (VM down,
# var unset/false) also falls back to 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-build"]') || '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' }}
needs: changes
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
# box has 31 GB and a single next-build peaks at 1416 GB RSS. On 2026-08-28
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
# its own so a release is never queued behind PR traffic; PR builds serialize
# among themselves. GitHub keeps one running + one pending per group and
# CANCELS older pendings — a cancelled PR build is re-runnable; a dead main
# build costs the publish its artefact and a 40-minute rebuild that OOMs.
concurrency:
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
cancel-in-progress: false
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
steps:
- uses: actions/checkout@v7
@@ -657,14 +646,14 @@ jobs:
# Keep standalone/node_modules intact: package/electron jobs consume the
# Next-traced standalone tree and must not replace it with root node_modules.
run: |
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
tar -czf /tmp/e2e-build.tar.gz \
--exclude='.build/next/cache' \
.build/next
- name: Upload Next.js build for downstream jobs
uses: actions/upload-artifact@v7
with:
name: next-build
path: ${{ runner.temp }}/e2e-build.tar.gz
path: /tmp/e2e-build.tar.gz
retention-days: 1
package-artifact:
@@ -687,14 +676,10 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
path: /tmp/
- name: Extract Next.js build artifact
run: |
tar -xzf next-build-artifact/e2e-build.tar.gz
tar -xzf /tmp/e2e-build.tar.gz
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
# it only rebuilds if the downloaded standalone artifact is missing.
- run: npm run build:cli
@@ -782,14 +767,10 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
path: /tmp/
- name: Extract Next.js build artifact
run: |
tar -xzf next-build-artifact/e2e-build.tar.gz
tar -xzf /tmp/e2e-build.tar.gz
- name: Install Electron dependencies
working-directory: electron
run: npm install --no-audit --no-fund
@@ -976,11 +957,7 @@ jobs:
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
# blocking job was green. The upload step now has its own ceiling; this is headroom.
timeout-minutes: 30
timeout-minutes: 20
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
env:
@@ -1059,10 +1036,6 @@ jobs:
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
# Informational means informational: its own ceiling and continue-on-error, so a
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
@@ -1257,14 +1230,10 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
path: /tmp/
- name: Extract Next.js build artifact
run: |
tar -xzf next-build-artifact/e2e-build.tar.gz
tar -xzf /tmp/e2e-build.tar.gz
# 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

View File

@@ -485,9 +485,6 @@ jobs:
severity: CRITICAL
ignore-unfixed: true
exit-code: "1"
# Explicit: the advisory scan above already points at it, and the blocking
# gate must honour the same accepted-risk list (#12084).
trivyignores: .trivyignore
- name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main'

View File

@@ -4,21 +4,12 @@ on:
push:
tags:
- "v*"
# A dispatch builds the ref it is dispatched ON (`gh workflow run … --ref v3.8.50` rebuilds
# that tag; `--ref main` builds the repaired line). The ref is deliberately NOT an input:
# CodeQL flags an input-controlled checkout next to the npm cache on the default branch as
# cache poisoning (actions/cache-poisoning/poisonable-step), and `github.ref` is trusted.
workflow_dispatch:
inputs:
version:
description: "Release version (e.g., v1.6.8)"
required: true
type: string
publish_npm:
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
required: false
default: true
type: boolean
# Least-privilege default: read-only at the top level; each job grants the writes it
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
@@ -413,14 +404,7 @@ jobs:
tag_name: ${{ needs.validate.outputs.version }}
draft: false
prerelease: false
# NEVER. Phase 3 of the release flow creates the GitHub Release with the curated
# notes seconds after pushing the tag, so by the time this step runs (1-2 h of
# builds later) the body already exists — and `true` APPENDS GitHub's
# auto-generated "What's Changed" block to it (v3.8.48 shipped that way; the
# v3.8.50 re-attach dispatch added +1,416 chars to a 121 KB body, run
# 33238093090). A curated body sits ~3 KB under the 125,000-char cap, so the
# append can also turn this step RED and leave the release with no assets.
generate_release_notes: false
generate_release_notes: true
fail_on_unmatched_files: false
files: |
release-assets/*.dmg
@@ -478,20 +462,11 @@ jobs:
publish-npm:
name: Publish to npm
needs: [validate, release]
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
permissions:
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
# A reusable workflow's job cannot request more permission than the caller grants,
# so a `read` here makes GitHub reject the run at startup (startup_failure).
#
# `actions: read` for the same reason: the called `publish` job downloads the next-build
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
# this block a superset of every job's permissions in npm-publish.yml.
actions: read
contents: write
id-token: write # npm provenance (forwarded to the reusable workflow)
packages: write # publish to npm.pkg.github.com

View File

@@ -68,7 +68,7 @@ jobs:
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
# no local noauth CLIs => zero machine-specific false positives) and no contention.
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
@@ -196,26 +196,6 @@ jobs:
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 Release branch not green: ${TARGET}"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`${TARGET}\` is release-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
@@ -237,7 +217,7 @@ jobs:
# On a push, only run for a push to main — a push to release/* is handled by
# release-green above. Schedule/dispatch always run (they also sweep main).
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
@@ -314,25 +294,6 @@ jobs:
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Close tracking issue when the branch is green again
if: steps.validate.outputs.exit == '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
# The open/update step above is the UPWARD half of the loop; without this
# step a stale "not green" issue outlives the fix and every base-green check
# (`AGENTS.md` → "Base-green check") keeps stamping new PRs as base-red inherited.
TITLE="🔴 main branch not green"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue close "$EXISTING" --repo "$GITHUB_REPOSITORY" --reason completed \
--comment "✅ \`main\` is main-green again at \`${GITHUB_SHA:0:9}\` — ${RUN_URL}. Auto-closed by Release-Green (continuous)."
echo "Closed issue #$EXISTING"
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7

View File

@@ -23,12 +23,11 @@ on:
- next
- historic
publish_mode:
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
required: false
default: "auto"
default: "staged"
type: choice
options:
- auto
- staged
- direct
workflow_call:
@@ -63,15 +62,11 @@ jobs:
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
# it is kept verbatim so the expression stays greppable against ci.yml.
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-build"]') || 'ubuntu-latest' }}
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
skip: ${{ steps.resolve.outputs.skip }}
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' }}
permissions:
actions: read # find + download the CI run's next-build artifact for this SHA
contents: write # gh release upload (attach SBOM to the GitHub Release)
id-token: write # npm provenance (GitHub Packages step)
id-token: write # npm provenance
packages: write # publish to npm.pkg.github.com
steps:
- name: Checkout
@@ -205,11 +200,8 @@ jobs:
exit 0
fi
RUN=""
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
# this 1.3 GB artefact there took 2732 min of the 76-min publish job — the
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
for candidate in $CANDIDATES; do
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
RUN="$candidate"
break
fi
@@ -219,8 +211,8 @@ jobs:
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
exit 0
fi
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
rm -rf "$RUNNER_TEMP/next-build"
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
rm -rf /tmp/next-build
if [ -f .build/next/standalone/server.js ]; then
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
else
@@ -273,20 +265,11 @@ jobs:
if-no-files-found: error
- name: Attach SBOM to GitHub Release
# Not only on the `release` event: the v3.8.50 package shipped through a
# workflow_dispatch (staged publish, 11 attempts) and this step was skipped, so the
# GitHub Release carried no SBOM until it was attached by hand from the run's
# `sbom-npm` artifact. Attach whenever a release for the published tag exists.
if: steps.resolve.outputs.skip != 'true' && (github.event_name == 'release' || github.event_name == 'workflow_dispatch')
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'release'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ github.event_name == 'release' && github.ref_name || format('v{0}', inputs.version) }}
run: |
if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::no GitHub Release for $TAG yet — SBOM stays on the sbom-npm workflow artifact"
exit 0
fi
gh release upload "$TAG" sbom-npm.cdx.json --repo "$GITHUB_REPOSITORY" --clobber
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
# 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
@@ -304,12 +287,7 @@ jobs:
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
- name: Prove clean-install AND upgrade-over-previous both boot
if: steps.resolve.outputs.skip != 'true'
# 60, not 30. This gate was added in #8953 and the 2026-08-27 v3.8.50 publish
# was the FIRST run to ever reach it — every earlier attempt died upstream, so
# its budget had never been measured against a real run. It then blew the limit
# on its debut: `npm pack` alone took 24m37s, leaving 5 minutes for two installs
# and two boots. 30 was a guess; 60 is sized to the one measurement we have.
timeout-minutes: 60
timeout-minutes: 30
run: npm run check:install-upgrade
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
@@ -331,133 +309,17 @@ jobs:
fi
npm --version
# The registry upload itself moved to the `stage-npm` job below: npm REFUSES
# `--provenance` from a self-hosted runner (422 "Unsupported GitHub Actions
# runner environment"), and the heavy verification above cannot move to a
# hosted one (16 GB is not enough for build:cli's next-build fallback — see
# this job's runs-on comment). So this job proves the bytes and hands them
# over; a tiny hosted job does the upload.
- name: Pack the verified tarball for the upload job
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
run: |
set -euo pipefail
# --ignore-scripts: prepublishOnly would re-run build:cli-api && build:cli,
# rebuilding bytes this job has already built, validated and boot-smoked.
npm pack --ignore-scripts
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "expected $TARBALL to exist after npm pack" >&2; ls -la ./*.tgz || true; exit 1; }
echo "packed $TARBALL ($(du -h "$TARBALL" | cut -f1))"
- name: Hand the tarball to the hosted publish job
if: steps.resolve.outputs.skip != 'true'
uses: actions/upload-artifact@v7
with:
name: npm-tarball
path: omniroute-${{ steps.resolve.outputs.version }}.tgz
retention-days: 1
if-no-files-found: error
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
- 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 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Configuring for GitHub Packages..."
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
npm pkg set name="@diegosouzapw/omniroute"
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
echo "✅ Action finished for GitHub Packages"
# npm REFUSES `--provenance` from a self-hosted runner:
# 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
# Unsupported GitHub Actions runner environment: "self-hosted".
# Only "github-hosted" runners are supported when publishing with provenance.
# v3.8.49 published fine because it predates USE_VPS_RUNNER being turned on
# (2026-08-02); v3.8.50 was the first release after it, so this had been latent
# for four weeks. Dropping --provenance was not an option: 3.8.49 carries a
# SLSA attestation and 3.8.50 must not regress that.
# The `publish` job cannot simply move to a hosted runner either — 16 GB is not
# enough for build:cli's next-build fallback. So it keeps proving the bytes and
# this job, which needs no memory at all, performs the upload.
stage-npm:
needs: publish
if: needs.publish.outputs.skip != 'true'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write # npm provenance — the whole reason this job is separate
steps:
- name: Download the tarball the publish job proved
uses: actions/download-artifact@v8
with:
name: npm-tarball
path: .
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Ensure npm supports staged publishing
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 a 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
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
# OIDC gives the same guarantee without the manual approve.
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
# VERSION can never claim `@latest`).
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
- name: Publish to npm (staged — owner approves with 2FA)
# Only on an explicit request now: Trusted Publishing below is the default.
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, the historic tag will NOT claim `@latest`.
# --ignore-scripts: publishing a built tarball must never re-run
# prepublishOnly (build:cli-api && build:cli) on this small runner.
npm stage publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
npm stage publish --provenance --access public --tag "$TAG"
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
@@ -473,18 +335,31 @@ jobs:
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Configuring for GitHub Packages..."
echo "//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}" > .npmrc
npm pkg set name="@diegosouzapw/omniroute"
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" \
|| echo "⚠️ omniroute@${VERSION} might already be published on GitHub Packages."
echo "✅ Action finished for GitHub Packages"
publish-opencode-plugin:
runs-on: ubuntu-latest
permissions:

View File

@@ -4,15 +4,12 @@ on:
schedule:
- cron: "27 7 * * 1"
push:
# Scorecard only accepts the DEFAULT branch — here the active release/vX.Y.Z,
# not `main`. The job below guards on it so a push to any other branch skips.
branches: ["main", "release/**"]
branches: ["main"]
permissions: read-all
jobs:
analysis:
if: ${{ github.event_name != 'push' || github.ref == format('refs/heads/{0}', github.event.repository.default_branch) }}
name: Scorecard analysis
runs-on: ubuntu-latest
permissions:

3
.gitignore vendored
View File

@@ -293,6 +293,3 @@ docker-compose.yml.bak
# Ad-hoc test sandboxes (never tracked — may contain local DBs)
/.sandbox/
.aider*
# check:install-upgrade work trees (~12 GB, disposable)
/.install-upgrade/

View File

@@ -19,12 +19,4 @@
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.
#
# CVE-2025-68121 — Go stdlib crypto/tls (session-resumption certificate validation)
# inside the PREBUILT bogdanfinn/tls-client v1.15.1 .so that tls-client-node's
# postinstall downloads (built with go 1.24.1; fixed in 1.24.13). No upstream
# rebuild exists (v1.15.1 is still the latest release) and nothing in this repo
# can bump it. The binary is only loaded by the browser-TLS web-provider
# executors (claude-web / grok-web / lmarena / perplexity-web / notion-web),
# whose handshakes go through utls. Tracking issue: #12084. Revisit at the next
# tls-client release or base-image bump and BEFORE the v3.8.51 tag (2026-09-15).
CVE-2025-68121
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)

View File

@@ -184,29 +184,19 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
# silently leaving no standalone bundle. Next derives the worker count from
# CIRCLE_NODE_TOTAL (workers = N-1). (#10060)
#
# Lowered 8 → 3 (7 workers → 2) in #11419, then 3 → 2 (2 workers → 1) in #7518.
# Every page-data worker inherits NODE_OPTIONS above, so the ceiling is per
# PROCESS, not per build: 7 workers on a 16 GB GitHub runner (ubuntu-24.04 /
# ubuntu-24.04-arm, 4 vCPU) exhausted the host and buildkit failed the whole
# step with `ResourceExhausted: ... cannot allocate memory`. The compile phase
# always finished ("✓ Compiled successfully in 4.2min"); the kernel killed the
# build right after "Collecting page data using N workers".
#
# #11419's first fix (8 → 3) modeled the per-worker peak as an INFERENCE
# (2560 MB, guessed from "7 workers didn't fit") and assumed the parent
# process's RSS tracked the V8 heap ceiling. Both assumptions were wrong: a
# live VPS reproduction (issue #7518, dmesg OOM-killer report) measured the
# real per-process RSS directly at ~4.5 GB, independent of the NODE_OPTIONS
# heap flag (Turbopack itself is native/Rust, outside the V8 heap) — and it
# applies to the parent process too, not just workers. 2 workers (3 processes
# × 4.5 GB = 13.5 GB) still didn't fit the 12.288 GB (75%) budget on a 16 GB
# runner, matching the still-live publish failures after #11419 merged. 1
# worker (2 processes × 4.5 GB = 9 GB) fits with headroom to spare.
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic against
# the measured figure and fails if either knob is raised past what a 16 GB
# runner holds. Override for a big builder: `--build-arg
# OMNIROUTE_BUILD_WORKERS=8`.
ARG OMNIROUTE_BUILD_WORKERS=2
# Lowered 8 → 3 (7 workers → 2). Every page-data worker inherits NODE_OPTIONS
# above, so the ceiling is per PROCESS, not per build: 7 workers on a 16 GB
# GitHub runner (ubuntu-24.04 / ubuntu-24.04-arm, 4 vCPU) exhausted the host and
# buildkit failed the whole step with `ResourceExhausted: ... cannot allocate
# memory`. The compile phase always finished ("✓ Compiled successfully in
# 4.2min"); the kernel killed the build right after "Collecting page data using
# 7 workers". It was intermittent for a while and went 100% on 2026-08-22, which
# is what a threshold being crossed by ordinary codebase growth looks like.
# tests/unit/docker-build-memory-budget.test.ts does the arithmetic and fails if
# either knob is raised past what a 16 GB runner holds. 2 workers also stops
# oversubscribing the runner's 4 vCPU, which 7 did. Override for a big builder:
# `--build-arg OMNIROUTE_BUILD_WORKERS=8`.
ARG OMNIROUTE_BUILD_WORKERS=3
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
COPY . ./

View File

@@ -1,5 +1,5 @@
# ── Multi-stage Dockerfile for Native Bun Runtime (web-latest-bun) ───────────
FROM oven/bun:1.4.0-slim AS base
FROM oven/bun:1.3.14-slim AS base
WORKDIR /app
RUN apt-get update \
@@ -34,10 +34,8 @@ RUN if [ -f "node_modules/tls-client-node/scripts/postinstall.js" ]; then \
bun node_modules/tls-client-node/scripts/postinstall.js || true; \
fi
# Turbopack is supported on Bun 1.4+ (Next 16.3); override via
# --build-arg OMNIROUTE_USE_TURBOPACK=0 to force the webpack fallback.
ARG OMNIROUTE_USE_TURBOPACK=1
ENV OMNIROUTE_USE_TURBOPACK=${OMNIROUTE_USE_TURBOPACK}
# Disable Turbopack for Bun builder stage (Turbopack V8 internal worker bindings require Node)
ENV OMNIROUTE_USE_TURBOPACK=0
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
@@ -48,33 +46,6 @@ ENV DASHBOARD_ALLOW_EMBED=$DASHBOARD_ALLOW_EMBED
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
# Cap the Next.js build heap and page-data worker pool inside the Bun image the
# same way the node Dockerfile does (#10060/#11419/#7518). Without these knobs
# Next falls back to its defaults: worker pool = os.cpus()-1 (3 on the 4-vCPU
# GitHub runner) and an 8 GB V8 heap ceiling per process. 4+ V8 processes at
# multi-GB each blow past the 16 GB runner, the cgroup OOM killer SIGKILLs a
# build worker mid-compile, and buildx fails the step with `ResourceExhausted:
# ... cannot allocate memory` — every Bun image published on main since the -bun
# targets landed (#11709, #11039).
#
# The per-process peak is a MEASURED ~4.5 GB RSS (dmesg OOM-killer report,
# #7518), independent of NODE_OPTIONS — Turbopack is native/Rust and compiles
# outside the V8 heap — and it applies to the parent process too, so 2 page-data
# workers (3 processes × 4.5 GB ≈ 13.5 GB) do not fit the 12.288 GB (75%)
# budget either. Both images therefore default to OMNIROUTE_BUILD_WORKERS=2
# (1 page-data worker): 2 processes × 4.5 GB ≈ 9 GB fits with headroom (#11663).
# The default Turbopack path keeps the compile outside the V8 heap, but the
# guards must hold for the webpack fallback (OMNIROUTE_USE_TURBOPACK=0) too, so
# they are wired exactly like the node image.
#
# NODE_OPTIONS propagates to the spawned `next build` child and its workers
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env), so the
# ceiling is per PROCESS, not per build.
ARG OMNIROUTE_BUILD_MEMORY_MB=6144
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
ARG OMNIROUTE_BUILD_WORKERS=2
ENV CIRCLE_NODE_TOTAL=${OMNIROUTE_BUILD_WORKERS}
# Bun native Next.js build execution
RUN bun run --quiet build

View File

@@ -1,4 +0,0 @@
- The npm publish is automatic again, through npm Trusted Publishing (OIDC): the hosted
`stage-npm` job publishes with a short-lived credential minted from GitHub's id-token —
no `NPM_TOKEN`, no 2FA prompt, provenance attached. `publish_mode=staged` (owner
approves with 2FA) and `direct` (token) remain available on `workflow_dispatch`.

View File

@@ -1 +0,0 @@
- **fix(docker):** bump the Bun image to 1.4.0, enable Turbopack on Bun, and port the node image's build memory guards so the `-bun` container builds fit the 16 GB GitHub runner instead of dying with `cannot allocate memory` ([#11719](https://github.com/diegosouzapw/OmniRoute/pull/11719)). Both images now default `OMNIROUTE_BUILD_WORKERS` to `2` (1 page-data worker) against the measured ~4.5 GB per-process RSS budget (#7518/#11663).

View File

@@ -1,4 +0,0 @@
- Excluded Next.js Node File Trace manifests (`*.nft.json`) from the published npm
tarball. They are build-time metadata and are never read while serving, but had
grown to 668.7 MB — 61% of the package — which pushed the upload past the
registry limit and made `npm publish` fail with `413 Payload Too Large`.

View File

@@ -1,5 +0,0 @@
- Fixed the Alibaba free-tier allowlist test that went red on its own once the
shipped catalog's `validUntil` (2026-08-27) passed, leaving every PR and `main`
with a failing `Unit Tests (1/8)`. The test now builds its own packs with dates
it controls, and covers the expired-pack fallback that production has actually
been serving.

View File

@@ -1,4 +0,0 @@
- Split the npm registry upload into its own GitHub-hosted job. npm refuses
`--provenance` from a self-hosted runner (`422 ... Only "github-hosted" runners
are supported`), which blocked the v3.8.50 publish; the heavy verification
cannot move to a hosted runner, so it now hands the proven tarball over instead.

View File

@@ -1 +0,0 @@
- Electron release: `electron/package-lock.json` regained the optional `electron-builder-squirrel-windows` subtree (13 entries) that `npm ci` had been refusing as out of sync — the Linux desktop leg died on it — and `electron-release.yml` gained a `build_ref` dispatch input so a release whose tag was cut with the broken lock can have its assets rebuilt from the repaired line

View File

@@ -1 +0,0 @@
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets

View File

@@ -1 +0,0 @@
- npm publish workflow: the CycloneDX SBOM is attached to the GitHub Release on `workflow_dispatch` publishes too (when a release for the tag exists), not only on the `release` event — v3.8.50 shipped through a staged dispatch and its release carried no SBOM until it was attached by hand from the run's `sbom-npm` artifact

View File

@@ -1,4 +0,0 @@
- Added a unit test that fails seven days before any dated pack under `config/`
(`validUntil` and sibling keys) lapses, naming the file and key. The Alibaba
free-tier pack expired on 2026-08-27 and turned every PR red the next morning
with no commit involved; renewal now happens on someone's terms, not the clock's.

View File

@@ -1,3 +0,0 @@
- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a
self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the
registry, which in v3.8.50 only surfaced after the tag and Docker images were public.

View File

@@ -1,5 +0,0 @@
- `scripts/ops/runner-janitor.sh` now proves a path is idle with one `lsof`
snapshot and removes stale leftovers itself (tmpfs after 3 h — it is RAM — disk
after 24 h), kills orphan `next-build` processes, prunes checkouts of stopped
runners, and alerts on memory pressure; `--dry-run` shows exactly what it would
do. `docs/ops/RUNNER_BOX.md` reconciled to the measured box (31 GB, 10 listeners).

View File

@@ -1,5 +0,0 @@
- The `next-build` artefact (1.3 GB) is now written and read under `$RUNNER_TEMP`
(per-runner, on disk) instead of `/tmp`, which on the self-hosted pool is a
12 GB tmpfs in RAM. Landing it there took 2732 of the publish job's 76 minutes,
and the fixed `/tmp/e2e-build.tar.gz` name let E2E jobs on different runners
overwrite each other's download.

View File

@@ -1,3 +0,0 @@
- The CI `build` job now runs in two concurrency lanes — `main` and pull requests —
so a release build is never queued behind (or OOM-killed beside) PR builds on the
self-hosted pool, which holds one `next-build` comfortably and two at the edge.

View File

@@ -1 +0,0 @@
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it

View File

@@ -1,4 +0,0 @@
- Every CI job that runs a `next build` (`build`, the npm `publish`, both release-green
validations) now targets the `omni-build` runner label, which only two of the eight
self-hosted runners carry. The box holds one build comfortably and two at the edge; a
third now queues on GitHub instead of being OOM-killed by the kernel.

View File

@@ -169,7 +169,7 @@
"dedicatedGate": true
},
"zizmorFindings": {
"value": 194,
"value": 192,
"_rebaseline_2026_08_20_radar_export_workflow": "190 -> 192 (+2). Workflow novo `.github/workflows/radar-export.yml` (passo 10 do go-live do Radar: publica o export estável do catálogo como asset de release para o servidor privado baixar via RADAR_EXPORT_URL). Os +2 são unpinned-uses @vN: actions/checkout@v7 + actions/setup-node@v7 — a MESMA convenção deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só este violaria a convenção. O findings artipacked do checkout foi CORRIGIDO com `persist-credentials: false` (o job publica via GH_TOKEN em `gh release`, não usa a credencial do checkout). Nenhuma classe nova de template-injection / cache-poisoning / dangerous-triggers. Medido local com zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 191; +1 do delta conhecido do runner (ver _rebaseline_2026_07_28_ci_runner_delta: o runner enxerga 1 unpinned-uses @vN a mais que o devbox no mesmo commit; a baseline segue o runner) => 192.",
"_rebaseline_2026_07_20_aliasresolver_hook_split_7808": "175 -> 176 (+1). Companion to PR #7808 (CodeQL js/incomplete-url-substring-sanitization fix in bin/aliasResolver.mjs). The +1 is NOT caused by this PR's code changes (bin/* is not a workflow file) — it is a pre-existing drift that surfaced because the ratchet gate runs on this PR's CI: the zizmor scanner version on the GitHub runner gained a new rule (or extended an existing one) since the v3.8.49 baseline was seeded on 2026-07-17. Breakdown: the new finding is an unpinned-uses @vN class item on one of the existing workflows (same deliberate convention as _scanner_harden_workflows_2026_06_16 — @vN is intentional, SHA-pinning only this one would violate the convention). No new template-injection/artipacked/cache-poisoning/dangerous-triggers classes introduced. Measured by the Quality Gates (Extended) job on run 29713001401 = 176, baseline was 175. Note: by the time this landed on release/v3.8.49, the baseline was already at 176 via _rebaseline_2026_07_17_combo_recovery_hints — this entry is kept as historical record; no further bump applied.",
"_rebaseline_2026_07_17_v3849_release": "169 -> 175 (+6). Cycle workflow drift (v3.8.48/v3.8.49): npm-publish.yml (new, WS1.3 #7092), electron-release.yml, nightly-compat.yml, nightly-release-green.yml, CI restructures (#7501 full-history base fetch, #7355 main-green, #7202 merge-queue gates, Trunk/Codecov). Breakdown vs v3.8.47: +3 unpinned-uses (@vN convention, deliberate per _scanner_harden_workflows_2026_06_16), +2 cache-poisoning (artifact upload/cache in the OWN electron-release/npm-publish RELEASE workflows -- operator-controlled, not fork-PR exploitable), +1 excessive-permissions (nightly-compat.yml permissions:issues). No new template-injection/artipacked/dangerous-triggers. Measured with zizmor 1.25.2 via `node scripts/check/check-workflows.mjs --ratchet` = 175 on da3a0be69.",
@@ -180,8 +180,7 @@
"_rebaseline_2026_06_23_v3834_release": "152 -> 155 (+3). The 3 new unpinned-uses are in .github/workflows/nightly-release-green.yml (added by #4622 this cycle): actions/checkout@v7, actions/setup-node@v6, actions/upload-artifact@v4 — the SAME deliberate @vN convention as ci.yml's own checkout@v7/setup-node@v6 and every other workflow (see _scanner_harden_workflows_2026_06_16 + _zizmor_rebaseline_2026_06_20_ci_build_artifact_reuse). SHA-pinning only this workflow would violate the convention. The workflow-lint ratchet does NOT run on PR->release fast-gates, so it surfaced only on the release PR; measured locally via `npm run check:workflows -- --ratchet` = 155. No new template-injection/artipacked/cache-poisoning.",
"_rebaseline_2026_07_13_v3847_release_preflight": "159 -> 169 (+10). Findings from cycle-merged workflow changes: #6716 (PR gate restructure), #6781 (unit fast-path shard 2->4), #6788 (TIA tsx loader split), #6881 (electron-updater latest.yml manifests in release assets) — same deliberate @vN unpinned-uses convention as prior rebaselines; no new template-injection/artipacked/cache-poisoning classes. Measured via `npm run check:workflows -- --ratchet` = 169 on the v3.8.47 release pre-flight.",
"_rebaseline_2026_07_28_v3849_release_preflight": "176 -> 189 (+13). Pre-flight de fechamento da v3.8.49 (934 commits no ciclo). Deriva de workflow: 1 workflow novo (build-rinseaid-image.yml) mais os bumps de action do Dependabot ao longo do ciclo — todos da MESMA classe unpinned-uses @vN, convenção deliberada do repo (ver _scanner_harden_workflows_2026_06_16); fixar por SHA só estes violaria a convenção. Nenhuma classe nova de template-injection / artipacked / cache-poisoning / dangerous-triggers. Nesta mesma passada foram CORRIGIDAS 3 diretivas shellcheck malformadas (SC1125: `# shellcheck disable=SC2086 — texto`, em que o travessão invalida o par key=value) em ci.yml e nightly-release-green.yml. Medido com zizmor 1.25.2 via `npm run check:workflows -- --ratchet` = 189.",
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner.",
"_rebaseline_2026_08_28_npm_publish_hosted_stage_job": "192 -> 194 (+2). Job novo `stage-npm` em .github/workflows/npm-publish.yml: o npm RECUSA `--provenance` vindo de runner self-hosted (422 \"Unsupported GitHub Actions runner environment\"), e o job `publish` nao pode migrar para runner hospedado porque 16 GB nao bastam para o fallback next-build do build:cli (documentado no proprio runs-on). A separacao foi a unica saida que preserva a atestacao SLSA que a 3.8.49 ja tem. Os +2 sao da MESMA convencao deliberada de todos os workflows (ver _scanner_harden_workflows_2026_06_16): unpinned-uses @vN em actions/download-artifact@v8 + actions/setup-node@v7, mais o cache-poisoning que o proprio setup-node@v7 ja gera nos outros 2 jobs deste MESMO arquivo (linhas 85 e 463) e que ja esta na baseline. Fixar por SHA so este job violaria a convencao. Nenhuma classe nova: zero template-injection / artipacked / dangerous-triggers / excessive-permissions — o job declara apenas contents:read + id-token:write, que e o minimo para a proveniencia. Medido pelo job Quality Gates (Extended) no run 33162... da PR #11877 = 194."
"_rebaseline_2026_07_28_ci_runner_delta": "189 -> 190 (+1). Medido 189 no devbox e 190 no runner do GitHub no MESMO commit (run 30396592013, job Quality Gates (Extended)) — mesma classe já registrada em _rebaseline_2026_07_20_aliasresolver_hook_split_7808: a versão do zizmor no runner enxerga uma finding a mais que a local, sempre da classe unpinned-uses @vN. O valor do runner é o que o gate compara, então a baseline segue o runner."
},
"vulnCount": {
"value": 22,

View File

@@ -226,22 +226,16 @@ Three build args control what the `builder` stage costs. They are build-time onl
| --------------------------- | ------- | ----------------------------------------------------------------------------------- |
| `OMNIROUTE_USE_TURBOPACK` | `1` | `0` builds with webpack instead. Lower peak memory, slower. |
| `OMNIROUTE_BUILD_MEMORY_MB` | `6144` | V8 heap ceiling (`--max-old-space-size`) for the spawned `next build`. |
| `OMNIROUTE_BUILD_WORKERS` | `2` | Feeds `CIRCLE_NODE_TOTAL`; Next derives `workers = N - 1` for page-data collection. |
| `OMNIROUTE_BUILD_WORKERS` | `3` | Feeds `CIRCLE_NODE_TOTAL`; Next derives `workers = N - 1` for page-data collection. |
`OMNIROUTE_BUILD_WORKERS` is the one to raise on a big builder and the one to
suspect when a constrained build dies **after** `✓ Compiled successfully`. Each
page-data worker is its own process, and so is the parent `next build` itself;
a live VPS reproduction (issue #7518) measured each process's peak RSS at
~4.5 GB independent of the `NODE_OPTIONS` heap flag (Turbopack compiles in
native/Rust memory outside the V8 heap). The default of `2` (→ 1 worker, 2
processes total) is sized for the 16 GB / 4 vCPU GitHub-hosted runners the
publish pipeline uses. At `8` (→ 7 workers) that runner ran out of memory and
buildkit failed the step with `ResourceExhausted: ... cannot allocate memory`;
`3` (→ 2 workers) still didn't fit once the per-process RSS was measured
directly instead of inferred. `tests/unit/docker-build-memory-budget.test.ts`
does the arithmetic against the measured figure and fails if either knob
outgrows the runner. Both images (node and Bun) share these defaults; the Bun
image's are set in `Dockerfile.bun` (Turbopack on Bun 1.4+, `#11719`).
page-data worker is its own process and inherits `NODE_OPTIONS`, so the heap
ceiling is per process, not per build: the default of `3` (→ 2 workers) is sized
for the 16 GB / 4 vCPU GitHub-hosted runners the publish pipeline uses. At `8`
(→ 7 workers) that runner ran out of memory and buildkit failed the step with
`ResourceExhausted: ... cannot allocate memory`. `tests/unit/docker-build-memory-budget.test.ts`
does the arithmetic and fails if either knob outgrows the runner.
Turbopack compiles in native Rust memory that lives **outside** the V8 heap, so
`OMNIROUTE_BUILD_MEMORY_MB` does not bound it. On a host with a memory ceiling the

View File

@@ -1,12 +1,12 @@
---
title: "Release Checklist"
version: 3.8.51
lastUpdated: 2026-08-28
version: 3.8.40
lastUpdated: 2026-06-28
---
# Release Checklist
> **Last updated:** 2026-08-28 — v3.8.51
> **Last updated:** 2026-06-28 — v3.8.40
> Streamlined release flow that leverages Claude Code skills for automation.
>
> **Keep the queue/branch green between releases:** see [RELEASE_GREEN.md](./RELEASE_GREEN.md)
@@ -37,21 +37,7 @@ npm run test:e2e # optional but recommended
/capture-release-evidences-cc
```
## npm Trusted Publishing (default since v3.8.51) — staged on request, direct as fallback
`npm-publish.yml` publishes through **npm Trusted Publishing (OIDC)** by default: the
`stage-npm` job (github-hosted) exchanges GitHub's id-token for a short-lived npm
credential for that run — no long-lived npm token in the repository secrets, no 2FA prompt, provenance attached.
That is the bypass npm sanctions now that tokens which skip 2FA are being retired;
it restores the fully automatic flow the project had up to v3.8.48 while keeping the
WS1.3 guarantee (a leaked token cannot publish alone — there is no token).
**One-time setup (owner):** npmjs.com → package `omniroute` → Settings → *Trusted
Publisher* → GitHub: owner `diegosouzapw`, repo `OmniRoute`, workflow `npm-publish.yml`
(environment: none). Until that exists, the automatic step fails with `ENEEDAUTH`:
re-dispatch with `publish_mode=staged` (below) or `direct`.
### Staged publishing (on request — `publish_mode=staged`)
## 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

View File

@@ -4,66 +4,32 @@ title: Self-Hosted Runner Box Operations
# Self-Hosted Runner Box Operations (.113 pool)
The self-hosted pool (`self-hosted, omni-release` on all eight runners; `omni-build` on two) runs on the **.113** box.
Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
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):
| resource | value | what it means for scheduling |
| --------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **10 listeners**: 8 OmniRoute + OmniHeuris + OmniMind | all share the memory above |
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
scp scripts/ops/runner-janitor.sh root@192.168.0.113:/opt/omniroute-ops/runner-janitor.sh
ssh root@192.168.0.113 'chmod +x /opt/omniroute-ops/runner-janitor.sh; apt-get install -y lsof'
# cron (root): every 30 min, log to /var/log/runner-janitor.log
*/30 * * * * MAX_ACTIVE_RUNNERS=8 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
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 -
```
`lsof` is required: the janitor proves a path is idle with one snapshot of open
files before removing it, and without the tool it removes nothing and says so
(exit 1). Try any change with `--dry-run` first — it prints exactly what it would
do and touches nothing.
What it does every run: sweeps our own leftovers (`runner-*`, `omniroute-*`,
`next-build*`, `e2e-build.tar.gz`) after **3 h on tmpfs** and 24 h on disk
`_work/_temp`; kills a `next-build` older than 75 min (no job runs that long — on
2026-08-27 one ran 70 min after GitHub had declared its job lost); prunes 48 h-old
checkouts of runners whose unit is **stopped**; alerts on disk ≥ 85 %, memory PSI
`full/avg60` ≥ 10 %, and more listeners than `MAX_ACTIVE_RUNNERS` (with an
omniroute/other breakdown). Exit 1 = attention needed; read the log.
## Runner units: KillMode
The runner's default `KillMode=process` leaves `Runner.Worker → npm → next-build`
alive when a unit is stopped or restarted — an orphan build keeps eating RAM and
CPU with no job attached. Every OmniRoute unit carries a drop-in
(`/etc/systemd/system/actions.runner.diegosouzapw-OmniRoute.<name>.service.d/10-killmode.conf`)
with `KillMode=mixed`: SIGTERM to the listener first, SIGKILL to the whole cgroup at
`TimeoutStop`. It takes effect on the unit's next restart — restart **one runner at
a time, only when idle**, with the idle check and the restart in the same command.
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
- **Heavy-build ceiling: 2 at a time — enforced by label.** Every job that runs a
`next build` (`ci.yml` `build`, `npm-publish.yml` `publish`, both `nightly-release-green`
validations) targets `[self-hosted, omni-build]`, and only **two** runners carry that
label (`omniroute-113-5`, `omniroute-113-6`, added through the runners API — no
re-registration). The other six keep `omni-release` and take nothing heavy; GitHub
queues a third build instead of the kernel killing one. Pair with the `heavy-build-*`
concurrency lanes in `ci.yml`. To add capacity, label another runner — never raise
the count past what 31 GB holds (one next-build ≈ 1416 GB).
- **Never clean `/tmp` or `_work` by hand while any runner is busy.** A
check-then-delete with a gap between the two is how a live Build job lost its
`_work` on 2026-08-27. The janitor does the check and the removal in one step;
let it.
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop` only
when its listener has no `Runner.Worker` child — and do it in one command.
- Workflows must not park artefacts in `/tmp` (it is RAM). Download to
`$RUNNER_TEMP` (on disk, per runner) — the 1.3 GB `next-build` artefact took 2732
minutes to land on the tmpfs and 2 minutes to upload from disk.
- **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

@@ -103,7 +103,6 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_MIGRATIONS_DIR` | _(auto-detect)_ | `src/lib/db/migrationRunner.ts` | Override the directory that the migration runner scans. Useful when shipping bundled migrations in custom builds. |
| `OMNIROUTE_EXTRA_MIGRATIONS_DIRS` | _(unset)_ | `src/lib/db/migrationRunner/extraDirs.ts` | Additional migration directories as `namespace=dir` entries separated by the platform path delimiter (e.g. `ee=/opt/app/enterprise/db/migrations`). Files found there are recorded as `<namespace>-<number>`, so a distribution shipping its own migrations never collides with the upstream numeric slots. A malformed entry, an invalid namespace or a missing directory throws at startup instead of silently skipping the schema. |
| `OMNIROUTE_MAX_PENDING_MIGRATIONS` | `50` | `src/lib/db/migrationRunner.ts` | Mass-pending-migrations safety threshold (#3416). Startup aborts if more than this many migrations are pending on an existing DB (guards against a wiped tracking table). Raise it to restore an older backup; set to `0` to disable the check. |
| `OMNIROUTE_INSTALL_UPGRADE_WORKDIR` | _(`<repo>/.install-upgrade`)_ | `scripts/check/check-install-upgrade.mjs` | Working directory for the `check:install-upgrade` release gate. It needs roughly 12 GB (two ~3 GB install trees plus the tarball), so it must not run on a small tmpfs — on the self-hosted runner `/tmp` is a 12 GB RAM-backed tmpfs and the gate exhausted it, truncating the package. |
| `OMNIROUTE_SPEND_FLUSH_INTERVAL_MS` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Flush interval (ms) for the batched spend/cost writer. Lower values reduce write coalescing; higher values reduce DB contention. |
| `OMNIROUTE_SPEND_MAX_BUFFER_SIZE` | _(default in code)_ | `src/lib/spend/batchWriter.ts` | Max buffered spend entries before a forced flush. Raise on high-QPS deployments; lower when bounded memory matters more. |
| `OMNIROUTE_PROXY_FETCH_DEBUG` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Set to `"true"` to emit `[ProxyFetch]` debug logs on the Vercel relay path. Off by default to avoid leaking routing hints. |

View File

@@ -297,45 +297,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@electron/windows-sign": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz",
"integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
"fs-extra": "^11.1.1",
"minimist": "^1.2.8",
"postject": "^1.0.0-alpha.6"
},
"bin": {
"electron-windows-sign": "bin/electron-windows-sign.js"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@electron/windows-sign/node_modules/fs-extra": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz",
"integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
"universalify": "^2.0.0"
},
"engines": {
"node": ">=14.14"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
@@ -1130,15 +1091,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-dirname": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz",
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1459,19 +1411,6 @@
"node": ">=14.0.0"
}
},
"node_modules/electron-builder-squirrel-windows": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-builder-squirrel-windows/-/electron-builder-squirrel-windows-26.15.3.tgz",
"integrity": "sha512-Jc19XPV9y9+2bAdZPkXuVNGNIEFBq9poHC61l8Kv6FdK7DRG3+Ic0rerC0DXOaeHNz8yW0fg/JnF8GQROOF5MA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.15.3",
"builder-util": "26.15.3",
"electron-winstaller": "5.4.0"
}
},
"node_modules/electron-publish": {
"version": "26.15.3",
"resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.15.3.tgz",
@@ -1506,66 +1445,6 @@
"tiny-typed-emitter": "^2.1.0"
}
},
"node_modules/electron-winstaller": {
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
"integrity": "sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
"fs-extra": "^7.0.1",
"lodash": "^4.17.21",
"temp": "^0.9.0"
},
"engines": {
"node": ">=8.0.0"
},
"optionalDependencies": {
"@electron/windows-sign": "^1.1.2"
}
},
"node_modules/electron-winstaller/node_modules/fs-extra": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz",
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
"universalify": "^0.1.0"
},
"engines": {
"node": ">=6 <7 || >=8"
}
},
"node_modules/electron-winstaller/node_modules/jsonfile": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz",
"integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==",
"dev": true,
"license": "MIT",
"peer": true,
"optionalDependencies": {
"graceful-fs": "^4.1.6"
}
},
"node_modules/electron-winstaller/node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
"integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -2480,20 +2359,6 @@
"node": ">= 18"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -2757,36 +2622,6 @@
"node": ">=18"
}
},
"node_modules/postject": {
"version": "1.0.0-alpha.6",
"resolved": "https://registry.npmjs.org/postject/-/postject-1.0.0-alpha.6.tgz",
"integrity": "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
"bin": {
"postject": "dist/cli.js"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/postject/node_modules/commander": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz",
"integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
},
"node_modules/proc-log": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz",
@@ -2981,21 +2816,6 @@
"node": ">= 4"
}
},
"node_modules/rimraf": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz",
"integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/roarr": {
"version": "2.15.4",
"resolved": "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz",
@@ -3251,21 +3071,6 @@
"node": ">=18"
}
},
"node_modules/temp": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz",
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/temp-file": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/temp-file/-/temp-file-3.4.0.tgz",

View File

@@ -52,8 +52,7 @@
"!**/*.test.js",
"!**/*.test.mjs",
"!**/*.spec.ts",
"!**/*.spec.tsx",
"!**/*.nft.json"
"!**/*.spec.tsx"
],
"workspaces": [
"open-sse",

View File

@@ -1,101 +0,0 @@
#!/usr/bin/env node
/**
* One-shot codemod (#11966): give every recursive temp-dir removal in tests the retry
* options Node already supports, so a WAL/backup/worker still writing into the directory
* turns into a retried delete instead of a red shard:
*
* rmSync(dir, { recursive: true, force: true })
* → rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
*
* Applies to `rmSync(`, `fs.rmSync(`, `rm(` / `fs.rm(` / `fs.promises.rm(` (async) and
* `rmdirSync(` calls whose option object literal contains `recursive: true` and no
* `maxRetries`. Only the option object is touched — call sites, assertions and imports are
* left as they are. Usage: node scripts/ad-hoc/codemod-rm-maxretries.mjs [dir=tests]
*/
import fs from "node:fs";
import path from "node:path";
const root = process.argv[2] || "tests";
const CALL = /\b(?:fs\.promises\.|fsp\.|fs\.|promises\.)?(?:rmSync|rmdirSync|rm)\(/g;
let files = 0;
let sites = 0;
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === "fixtures") continue;
walk(p, out);
} else if (/\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(e.name)) out.push(p);
}
return out;
}
// Find the closing brace of the option object literal that starts at `open`.
function objectEnd(src, open) {
let depth = 0;
for (let i = open; i < src.length; i++) {
const c = src[i];
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) return i;
} else if (c === '"' || c === "'" || c === "`") {
const q = c;
i++;
while (i < src.length && src[i] !== q) {
if (src[i] === "\\") i++;
i++;
}
}
}
return -1;
}
for (const file of walk(root)) {
const src = fs.readFileSync(file, "utf8");
let out = "";
let last = 0;
let touched = 0;
for (const m of src.matchAll(CALL)) {
const callStart = m.index + m[0].length;
// Locate the option object: the first `{` before the call's closing paren at depth 0.
let depth = 0;
let objOpen = -1;
for (let i = callStart; i < src.length; i++) {
const c = src[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") {
if (depth === 0) break;
depth--;
} else if (c === "{" && depth === 0) {
objOpen = i;
break;
}
}
if (objOpen === -1) continue;
const objClose = objectEnd(src, objOpen);
if (objClose === -1) continue;
const obj = src.slice(objOpen, objClose + 1);
if (!/\brecursive:\s*true\b/.test(obj) || /\bmaxRetries\b/.test(obj)) continue;
// Insert before the closing brace, respecting an existing trailing comma / newline.
const inner = obj.slice(1, -1);
const trimmed = inner.replace(/\s+$/, "");
const trailing = inner.slice(trimmed.length);
const sep = trimmed.endsWith(",") ? " " : ", ";
const multiline = /\n/.test(trailing);
const insert = multiline
? `${trimmed}${trimmed.endsWith(",") ? "" : ","}\n${trailing.replace(/\n$/, "")} maxRetries: 5,\n retryDelay: 100,${trailing}`
: `${trimmed}${sep}maxRetries: 5, retryDelay: 100${trailing}`;
out += src.slice(last, objOpen + 1) + insert;
last = objClose;
touched++;
}
if (touched) {
out += src.slice(last);
fs.writeFileSync(file, out);
files++;
sites += touched;
}
}
console.log(`[codemod-rm-maxretries] ${sites} call site(s) in ${files} file(s) under ${root}`);

View File

@@ -131,15 +131,9 @@ function runNextBuild() {
}
export function resolveNextBuildBundlerFlag(baseEnv = process.env) {
// Turbopack is the default; OMNIROUTE_USE_TURBOPACK=0 is the documented escape hatch
// to webpack (Windows, native-binding trouble, RAM-constrained machines — #6409, and
// docs/reference/ENVIRONMENT.md). The choice is env-only ON PURPOSE: the variable is
// the operator's control and CI sets it explicitly, so sniffing the runtime here would
// silently override an operator who asked for Turbopack. Bun 1.4+ supports Turbopack's
// V8 worker bindings (#11471), so the historical `process.versions.bun` → `--webpack`
// hardcode is gone; the `OMNIROUTE_USE_TURBOPACK=0` fallback remains for Bun < 1.4
// images built with the webpack path.
if (baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
// Turbopack is the default on Node.js; on Bun or when explicitly disabled (=0),
// use Webpack (--webpack) to avoid Turbopack V8 internal worker API mismatches.
if (process.versions.bun || baseEnv.OMNIROUTE_USE_TURBOPACK === "0") {
return "--webpack";
}
return "--turbopack";

View File

@@ -302,7 +302,7 @@ export function assertNoDiskExhaustion(output, label) {
throw new Error(
`${label}: the install ran out of disk space (${count} ENOSPC error(s) from npm). ` +
`The package tree is truncated, so anything measured from it — boot, schema, ` +
`migrations — is meaningless. Free space in ${workDirForMessages} (each install tree is ` +
`migrations — is meaningless. Free space in ${os.tmpdir()} (each install tree is ` +
`~3 GB) and re-run. This is an environment failure, NOT a schema divergence.`
);
}
@@ -318,11 +318,6 @@ function freeBytes(dir) {
const GB = 1024 ** 3;
// Set once the work directory exists, so the ENOSPC message names the filesystem that
// actually ran out — pointing at /tmp when the gate works elsewhere sends the reader to
// free space on the wrong volume (which is what happened during the v3.8.50 publish).
let workDirForMessages = os.tmpdir();
function resolvePreviousVersion(current, explicit) {
if (explicit) return explicit;
const out = execFileSync("npm", ["view", "omniroute", "dist-tags.latest"], { encoding: "utf8" });
@@ -353,25 +348,11 @@ async function main() {
}
const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
const allowlist = loadAllowlist(ROOT);
// NOT os.tmpdir(): on the self-hosted runner /tmp is a 12 GB tmpfs backed by RAM, while
// the root filesystem has ~66 GB free. This gate needs ~12 GB, so it exhausted the tmpfs
// and npm truncated the package — 58269 ENOSPC errors on the v3.8.50 publish, which the
// previous code could only report as a crash. Freeing disk did not help because the disk
// was never the constraint. Work on real disk beside the repo instead.
const workRoot =
process.env.OMNIROUTE_INSTALL_UPGRADE_WORKDIR || path.join(ROOT, ".install-upgrade");
fs.mkdirSync(workRoot, { recursive: true });
const tmp = fs.mkdtempSync(path.join(workRoot, "omniroute-install-upgrade-"));
workDirForMessages = tmp;
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-install-upgrade-"));
const failures = [];
const warnings = [];
try {
// Timed, because this turned out to be the expensive part: on the 2026-08-27
// v3.8.50 publish `npm pack` alone took 24m37s, leaving 5 of the step's 30-minute
// budget for two installs and two boots. Without a duration here the log showed
// only "packing…" then a timeout, which reads like a hang and is not.
const packStarted = Date.now();
log(`packing v${version}`);
const packOut = execFileSync("npm", ["pack", "--json", "--pack-destination", tmp], {
cwd: ROOT,
@@ -379,8 +360,6 @@ async function main() {
maxBuffer: 128 * 1024 * 1024,
});
const tarball = path.join(tmp, pickTarball(packOut));
const packMb = (fs.statSync(tarball).size / 1024 / 1024).toFixed(1);
log(`packed in ${Math.round((Date.now() - packStarted) / 1000)}s (${packMb} MB)`);
// Each install tree is ~3 GB and this run builds two of them, side by side, plus the
// ~275 MB tarball. On the v3.8.50 publish run that overflowed the runner disk mid-way
@@ -388,7 +367,7 @@ async function main() {
// exited 0, and every later measurement was taken from a broken tree.
const availableBytes = freeBytes(tmp);
if (availableBytes !== null) {
log(`free space in ${tmp}: ${(availableBytes / GB).toFixed(1)} GB`);
log(`free space in ${os.tmpdir()}: ${(availableBytes / GB).toFixed(1)} GB`);
if (availableBytes < 12 * GB) {
warn(
`only ${(availableBytes / GB).toFixed(1)} GB free — this gate needs roughly 12 GB ` +

View File

@@ -42,7 +42,6 @@ import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs";
const ROOT = process.cwd();
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
@@ -276,23 +275,6 @@ export function runZizmor(workflowsDir) {
// Main
// ---------------------------------------------------------------------------
/**
* Hard rule (not a lint count): `--provenance` inside a job that runs on a
* self-hosted runner. npm answers 422 at the registry, and in v3.8.50 that
* answer only came after the tag, the GitHub Release and the Docker images were
* already out. Blocks under --strict AND --ratchet (the CI mode); plain mode
* reports it like everything else.
* @param {string[]} files absolute workflow paths
*/
export function runProvenanceRunnerCheck(files) {
const findings = [];
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
findings.push(...findProvenanceOnSelfHosted(text, path.relative(ROOT, file)));
}
return findings;
}
function main() {
const hasActionlint = isBinaryAvailable("actionlint");
const hasZizmor = isBinaryAvailable("zizmor");
@@ -368,16 +350,6 @@ function main() {
}
}
const provenanceFindings = runProvenanceRunnerCheck(workflowFiles);
if (provenanceFindings.length > 0) {
console.error(
`[check-workflows] provenance×self-hosted: ${provenanceFindings.length} finding(s) — HARD RULE:`
);
provenanceFindings.forEach((f) => console.error(` ${formatProvenanceFinding(f)}`));
} else if (!QUIET) {
console.log("[check-workflows] provenance×self-hosted: OK (0 findings)");
}
const total = actionlintCount + zizmorCount;
process.stdout.write(`workflowFindings=${total}\n`);
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
@@ -385,15 +357,6 @@ function main() {
// Read this line with the count above: a finding total is only reproducible against the
// version that produced it. See zizmorVersion().
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
process.stdout.write(`provenanceRunnerFindings=${provenanceFindings.length}\n`);
if ((STRICT || RATCHET) && provenanceFindings.length > 0) {
console.error(
`\n[check-workflows] FAIL — ${provenanceFindings.length} job(s) publish with --provenance from a self-hosted runner.\n` +
" npm rejects that with 422 at the registry. Move the upload step to a github-hosted job\n" +
" (see .github/workflows/npm-publish.yml `stage-npm` for the pattern)."
);
process.exit(1);
}
if (STRICT && total > 0) {
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);

View File

@@ -1,90 +0,0 @@
/**
* scripts/check/lib/configExpiry.mjs
*
* Finds dated validity fields in JSON config packs so a test can fail BEFORE
* they lapse. Origin: config/alibaba-free-tier-allowlist.json carried
* `"validUntil": "2026-08-27"`; on 2026-08-28 the loader started (correctly)
* rejecting the pack and a test that asserted "the shipped pack loads" turned
* every PR and main red with no commit involved (#11866). A time bomb, not a
* regression — and the only kind of defect a diff review can never catch.
*
* Pure helpers; the repo-wide assertion lives in
* tests/unit/config-expiry-time-bomb.test.ts.
*/
import fs from "node:fs";
import path from "node:path";
export const EXPIRY_KEY =
/^(validUntil|valid_until|validTo|valid_to|expiresAt|expires_at|expiry|expires)$/;
const DAY_MS = 86_400_000;
/**
* Walks a parsed JSON value and returns every string-valued expiry field.
* @returns {{ file: string, keyPath: string, raw: string, expiresAt: number|null }[]}
*/
export function collectExpiryFields(value, file, keyPath = []) {
const out = [];
if (Array.isArray(value)) {
value.forEach((v, i) => out.push(...collectExpiryFields(v, file, [...keyPath, String(i)])));
return out;
}
if (!value || typeof value !== "object") return out;
for (const [key, v] of Object.entries(value)) {
const kp = [...keyPath, key];
if (EXPIRY_KEY.test(key) && typeof v === "string") {
const ms = Date.parse(v);
out.push({ file, keyPath: kp.join("."), raw: v, expiresAt: Number.isFinite(ms) ? ms : null });
} else if (v && typeof v === "object") {
out.push(...collectExpiryFields(v, file, kp));
}
}
return out;
}
/** @returns {"expired"|"expiring"|"ok"|"unparseable"} */
export function classifyExpiry(field, nowMs, warnDays = 7) {
if (field.expiresAt === null) return "unparseable";
if (field.expiresAt < nowMs) return "expired";
if (field.expiresAt < nowMs + warnDays * DAY_MS) return "expiring";
return "ok";
}
/** All *.json under dir, recursively, skipping node_modules. Sorted for stable output. */
export function walkJsonFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(current, e.name);
if (e.isDirectory()) {
if (e.name !== "node_modules") stack.push(full);
} else if (e.isFile() && e.name.endsWith(".json")) {
out.push(full);
}
}
}
return out.sort();
}
/**
* Scans every JSON file under `dir`; `file` in the result is relative to `dir`
* with forward slashes, so allowlists can key on it portably.
*/
export function scanConfigExpiry(dir) {
return walkJsonFiles(dir).flatMap((f) => {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(f, "utf8"));
} catch {
return []; // not this scanner's job to validate JSON
}
return collectExpiryFields(parsed, path.relative(dir, f).split(path.sep).join("/"));
});
}

View File

@@ -1,83 +0,0 @@
/**
* scripts/check/lib/provenanceRunner.mjs
*
* npm refuses `--provenance` from a self-hosted runner:
*
* 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
* Unsupported GitHub Actions runner environment: "self-hosted".
* Only "github-hosted" runners are supported when publishing with provenance.
*
* v3.8.50 hit this at the very end of a 76-minute publish job — after the tag,
* the GitHub Release and the Docker images were already public — because
* `USE_VPS_RUNNER` had been turned on (2026-08-02) with no release in between to
* surface it. The combination is greppable, so it must fail in CI the moment a
* workflow introduces it, not four weeks later at the registry.
*
* Pure: takes workflow YAML text, returns the offending (job, step) pairs.
*/
import { load as yamlLoad } from "js-yaml";
const SELF_HOSTED = /\bself-hosted\b/;
const EXPRESSION = /\$\{\{/;
// Lookahead, not \b: `--provenance-file=…` is a different flag (a pre-built
// bundle) and must not match — a word boundary sits between "e" and "-".
const PROVENANCE = /(^|\s)--provenance(?=\s|=|$)/m;
/**
* Classifies a job's `runs-on` value.
* @returns {"self-hosted"|"hosted"|"unknown"}
* "unknown" = an expression with no literal `self-hosted` in it (e.g.
* `${{ matrix.os }}`); the check does not guess, it skips.
*/
export function classifyRunsOn(runsOn) {
if (runsOn == null) return "unknown";
if (typeof runsOn === "string") {
if (SELF_HOSTED.test(runsOn)) return "self-hosted";
return EXPRESSION.test(runsOn) ? "unknown" : "hosted";
}
if (Array.isArray(runsOn)) {
return runsOn.some((v) => typeof v === "string" && SELF_HOSTED.test(v))
? "self-hosted"
: "hosted";
}
if (typeof runsOn === "object") {
// { group: ..., labels: ... } form
const labels = runsOn.labels;
return classifyRunsOn(Array.isArray(labels) ? labels : labels == null ? "" : String(labels));
}
return "unknown";
}
/**
* @param {string} yamlText
* @param {string} fileName used only for reporting
* @returns {{ file: string, job: string, step: string }[]}
*/
export function findProvenanceOnSelfHosted(yamlText, fileName = "<workflow>") {
let doc;
try {
doc = yamlLoad(yamlText);
} catch {
// actionlint owns syntax; an unparseable file is not this rule's finding.
return [];
}
const jobs =
doc && typeof doc === "object" && doc.jobs && typeof doc.jobs === "object" ? doc.jobs : {};
const findings = [];
for (const [jobName, job] of Object.entries(jobs)) {
if (!job || typeof job !== "object") continue;
if (classifyRunsOn(job["runs-on"]) !== "self-hosted") continue;
const steps = Array.isArray(job.steps) ? job.steps : [];
steps.forEach((step, i) => {
if (step && typeof step.run === "string" && PROVENANCE.test(step.run)) {
findings.push({ file: fileName, job: jobName, step: step.name || `#${i + 1}` });
}
});
}
return findings;
}
/** Human-readable line per finding, used by the CLI. */
export function formatProvenanceFinding(f) {
return `${f.file}: job "${f.job}", step "${f.step}" runs \`--provenance\` on a self-hosted runner — npm rejects that (422). Move the upload to a github-hosted job.`;
}

View File

@@ -123,48 +123,6 @@ function discoverPackagedExecutable() {
throw new Error(`Packaged Electron smoke check does not support ${platform()}.`);
}
/**
* The packaged app opens SQLite lazily: `/login` (the readiness URL) never touches the
* database, so a smoke that only waits for readiness sees no `[DB]` line at all. After
* readiness the smoke requests a DB-backed endpoint and waits for evidence that the
* database opened. The primary open path does NOT print "[DB] Driver: ..." (only the
* recovery path and the sql.js fallback do), so the evidence is any `[DB]`/`[Migration]`
* startup line — and the #7592 guard below rejects the fallback's own line explicitly.
*/
export const DB_TOUCH_PATH = "/api/monitoring/health";
export const DB_OPEN_EVIDENCE_PATTERN =
/\[DB\] (Driver: |SQLite database ready|Added [^\n]* column|Changing cache_size|cache_size changed)|\[Migration\] (Applied|Pre-migration backup)/;
export async function waitForDatabaseOpen(getLogs, { timeoutMs = 15_000, pollMs = 250 } = {}) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const logs = getLogs();
assertNoFatalLogs(logs);
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return logs;
await sleep(pollMs);
}
throw new Error(
`Packaged Electron app logged no [DB]/[Migration] startup line within ${timeoutMs}ms of ` +
`touching ${DB_TOUCH_PATH} — the database never opened, so the SQLite driver cannot be verified.`
);
}
async function openDatabaseForSmoke({ logs, smokeUrl }) {
const touchUrl = new URL(DB_TOUCH_PATH, smokeUrl).toString();
try {
const response = await fetchWithTimeout(touchUrl, 5_000);
console.log(
`[electron-smoke] touched ${touchUrl} (HTTP ${response.status}) to open the database`
);
} catch (error) {
console.log(
`[electron-smoke] touching ${touchUrl} failed (${error instanceof Error ? error.message : String(error)}) — waiting for the database anyway`
);
}
await waitForDatabaseOpen(() => logs.value);
console.log("[electron-smoke] database opened");
}
async function fetchWithTimeout(url, timeoutMs) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
@@ -492,13 +450,8 @@ export function assertNativeDriverSelected(logs) {
);
}
// The primary open path prints no "[DB] Driver: ..." line at all (only the recovery path and
// the sql.js fallback do), so a database that demonstrably opened WITHOUT the fallback's own
// line is the native driver — that is exactly what #7592 guards.
if (DB_OPEN_EVIDENCE_PATTERN.test(logs)) return;
throw new Error(
"Packaged Electron app logs show no database activity at all — cannot confirm which SQLite " +
"Packaged Electron app logs contain no '[DB] Driver: ...' line — cannot confirm which SQLite " +
"driver loaded."
);
}
@@ -553,14 +506,7 @@ async function waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState })
* by the single-launch path and the cold-restart (two-launch) path so both
* exercise identical spawn/readiness/shutdown behavior.
*/
async function launchAndCollectLogs({
appExecutable,
smokeUrl,
dataDir,
timeoutMs,
settleMs,
streamLogs,
}) {
async function launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs }) {
const smokeEnv = buildSmokeEnv({ dataDir });
await assertPortIsFree(smokeUrl);
await ensureSmokeEnvDirs(smokeEnv, dataDir);
@@ -592,8 +538,6 @@ async function launchAndCollectLogs({
try {
await waitForReady({ logs, smokeUrl, timeoutMs, settleMs, exitState });
// Outside waitForReady on purpose: a missing database is a verdict, not a readiness retry.
await openDatabaseForSmoke({ logs, smokeUrl });
return logs.value;
} catch (error) {
if (!streamLogs) {
@@ -624,14 +568,7 @@ async function main() {
!process.env.ELECTRON_SMOKE_DATA_DIR && process.env.ELECTRON_SMOKE_KEEP_DATA !== "1";
try {
await launchAndCollectLogs({
appExecutable,
smokeUrl,
dataDir,
timeoutMs,
settleMs,
streamLogs,
});
await launchAndCollectLogs({ appExecutable, smokeUrl, dataDir, timeoutMs, settleMs, streamLogs });
if (!coldRestart) return;

View File

@@ -1,172 +1,53 @@
#!/usr/bin/env bash
# runner-janitor — self-hosted runner box hygiene for the .113 pool.
# runner-janitor — self-hosted runner box hygiene (WS3.3, v3.8.49 quality plan).
#
# Runs from cron every 30 min (see docs/ops/RUNNER_BOX.md). It ACTS on what it
# can prove is safe and ALERTS on what needs an operator decision. Reads of
# "is this in use?" and the removal happen in the same command, never in two
# passes: a check-then-delete with a gap is how a live Build job lost its _work
# on 2026-08-27.
# 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
#
# Measured box (2026-08-28): 31 GB RAM, 32 cores, 15 GB swap, /tmp = 12 GB
# tmpfs (RAM!), 188 GB disk. A single `next-build` peaks at ~14 GB, so two
# concurrent heavy builds saturate the box and three take it down (06:42Z that
# day: load 56, two jobs lost). The v3.8.50 postmortem (Parte III) has the numbers.
#
# What it does, in order:
# 1) sweep stale artefacts our tooling leaves behind — tmpfs bases after 3 h
# (they hold RAM), disk _work/_temp bases after 24 h; only names we create,
# only when no process has them open
# 2) kill zombie builds: a `next-build` older than ZOMBIE_BUILD_MAX_MIN has no
# job attached (a real Build step measures ~26 min). On 2026-08-27 one ran
# 70 minutes after GitHub had already declared its job lost, eating 3.6 GB
# and a full core set. KillMode=mixed on the units covers systemctl
# stop/restart; this covers the lost-connection path.
# 3) prune 48 h-old checkouts under _work of runners whose unit is INACTIVE
# (stopped runners cannot be mid-job; active ones are never touched)
# 4) alert: root disk >= DISK_ALERT_PCT, memory PSI full/avg60 >= threshold,
# Runner.Listener count above the ceiling (with a per-project breakdown —
# the box also hosts OmniHeuris and OmniMind runners)
#
# Usage: runner-janitor.sh [--dry-run] [--help]
# Exit codes: 0 healthy · 1 attention needed (printed to stdout for the log).
set -euo pipefail
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h|--help)
sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-8}"
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-4}"
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
TMPFS_MAX_AGE_HOURS="${TMPFS_MAX_AGE_HOURS:-3}"
WORK_TEMP_MAX_AGE_HOURS="${WORK_TEMP_MAX_AGE_HOURS:-24}"
WORK_CHECKOUT_MAX_AGE_HOURS="${WORK_CHECKOUT_MAX_AGE_HOURS:-48}"
ZOMBIE_BUILD_MAX_MIN="${ZOMBIE_BUILD_MAX_MIN:-75}"
ZOMBIE_BUILD_COMM="${ZOMBIE_BUILD_COMM:-next-build}"
PSI_FULL_AVG60_ALERT="${PSI_FULL_AVG60_ALERT:-10}"
# Overridable so the unit test can point everything at a fixture tree.
JANITOR_TMP_BASES="${JANITOR_TMP_BASES-/tmp}"
JANITOR_WORK_TEMP_BASES="${JANITOR_WORK_TEMP_BASES-/opt/actions-runner*/_work/_temp /home/*/actions-runner*/_work/_temp}"
JANITOR_RUNNER_DIRS="${JANITOR_RUNNER_DIRS-/opt/actions-runner*}"
JANITOR_PSI_FILE="${JANITOR_PSI_FILE:-/proc/pressure/memory}"
JANITOR_DF_PATH="${JANITOR_DF_PATH:-/}"
WORK_DIR_MAX_AGE_HOURS="${WORK_DIR_MAX_AGE_HOURS:-24}"
STATUS=0
say() { echo "[janitor] $*"; }
# "Is anything using this?" — ONE snapshot of every open path on the box
# (lsof -Fn), then a prefix match per candidate. `lsof +D <dir>` walks the whole
# tree instead and took minutes on a 5 GB leftover — unusable from cron. An
# absent lsof means "cannot prove idle": the sweep keeps the path and says so.
LSOF_BIN="${JANITOR_LSOF:-lsof}"
have_busy_tools() { command -v "$LSOF_BIN" >/dev/null 2>&1; }
SNAP=""
cleanup() { [ -n "$SNAP" ] && rm -f -- "$SNAP"; }
trap cleanup EXIT
# One lsof for the whole run (~13 s / 83k lines on the box), kept ONLY for the
# bases we sweep — 460 candidates grepping a re-printed 83k-line string was the
# slow part, not lsof itself.
snapshot_open_paths() {
have_busy_tools || return 0
SNAP=$(mktemp) || return 0
local prefixes="" b
for b in $JANITOR_TMP_BASES $JANITOR_WORK_TEMP_BASES; do [ -d "$b" ] && prefixes="$prefixes"$'\n'"$b/"; done
# -F n: one "n<path>" line per open file; -w: no warnings
"$LSOF_BIN" -w -Fn 2>/dev/null | sed -n 's/^n//p' | grep -F -f <(printf '%s' "$prefixes" | sed '/^$/d') > "$SNAP" 2>/dev/null || true
}
is_busy() {
local p="$1"
[ -n "$SNAP" ] && [ -s "$SNAP" ] || return 1
# exact path, or anything beneath it when it is a directory
grep -qxF -- "$p" "$SNAP" && return 0
[ -d "$p" ] && grep -qF -- "$p/" "$SNAP"
}
echo "[janitor] $(date -u +%FT%TZ) start"
# sweep <base> <max-age-minutes>: only names our tooling creates, never through
# a symlinked base, never across a filesystem, and remove+check in one step.
sweep() {
local base="$1" max_min="$2" p
[ -d "$base" ] || return 0
[ -L "$base" ] && { say "skip symlinked base: $base"; return 0; }
while IFS= read -r -d '' p; do
if ! have_busy_tools; then say "cannot prove idle (lsof missing — apt install lsof), kept: $p"; STATUS=1; continue; fi
if is_busy "$p"; then say "busy, kept: $p"; continue; fi
if [ "$DRY_RUN" -eq 1 ]; then say "would remove ($(( max_min / 60 ))h+): $p"; else rm -rf -- "$p" && say "removed ($(( max_min / 60 ))h+): $p"; fi
done < <(find -P "$base" -xdev -mindepth 1 -maxdepth 1 \
\( -name 'runner-*' -o -name 'omniroute-*' -o -name 'next-build*' -o -name 'e2e-build.tar.gz' \) \
! -type l -mmin "+$max_min" -print0 2>/dev/null || true)
}
say "$(date -u +%FT%TZ) start${DRY_RUN:+ (dry-run=$DRY_RUN)} busy-tools=$(have_busy_tools && echo ok || echo MISSING)"
# 1) stale artefacts — tmpfs is RAM, so it gets the short fuse
snapshot_open_paths
for base in $JANITOR_TMP_BASES; do sweep "$base" $(( TMPFS_MAX_AGE_HOURS * 60 )); done
for base in $JANITOR_WORK_TEMP_BASES; do sweep "$base" $(( WORK_TEMP_MAX_AGE_HOURS * 60 )); done
say "stale temp sweep done"
# 2) zombie builds
ZOMBIES=0
while read -r pid etimes comm; do
[ -n "${pid:-}" ] || continue
if [ "$etimes" -gt $(( ZOMBIE_BUILD_MAX_MIN * 60 )) ]; then
say "⚠ zombie build pid=$pid comm=$comm age=$(( etimes / 60 ))min > ${ZOMBIE_BUILD_MAX_MIN}min — no job runs this long"
if [ "$DRY_RUN" -eq 1 ]; then say "[dry-run] would: kill -TERM $pid (then -KILL)"; else
kill -TERM "$pid" 2>/dev/null || true; sleep 10
kill -0 "$pid" 2>/dev/null && { kill -KILL "$pid" 2>/dev/null || true; say " needed SIGKILL"; }
fi
ZOMBIES=$(( ZOMBIES + 1 )); STATUS=1
fi
done < <(ps -eo pid=,etimes=,comm= 2>/dev/null | awk -v c="$ZOMBIE_BUILD_COMM" '$3 ~ ("^" c) {print $1, $2, $3}' || true)
say "zombie builds: $ZOMBIES"
# 3) old checkouts of STOPPED runners
for d in $JANITOR_RUNNER_DIRS; do
[ -d "$d" ] && [ -f "$d/.runner" ] || continue
agent=$(grep -o '"agentName": *"[^"]*"' "$d/.runner" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$agent" ] || continue
unit=$(systemctl list-units --plain --no-legend "actions.runner.*.${agent}.service" 2>/dev/null | awk 'NR==1{print $1}')
[ -n "$unit" ] || continue
if systemctl is-active --quiet "$unit"; then continue; fi
while IFS= read -r -d '' co; do
if [ "$DRY_RUN" -eq 1 ]; then say "would prune checkout of stopped runner $agent: $co"; else rm -rf -- "$co" && say "pruned checkout of stopped runner $agent: $co"; fi
done < <(find -P "$d/_work" -xdev -mindepth 2 -maxdepth 2 -type d -mmin "+$(( WORK_CHECKOUT_MAX_AGE_HOURS * 60 ))" -print0 2>/dev/null || true)
# 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"
# 4a) disk
USAGE=$(df --output=pcent "$JANITOR_DF_PATH" 2>/dev/null | tail -1 | tr -dc '0-9')
if [ "${USAGE:-0}" -ge "$DISK_ALERT_PCT" ]; then
say "⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"; STATUS=1
else
say "disk ${USAGE:-?}% OK"
fi
# 4b) memory pressure (PSI) — the box swapped its way through the v3.8.50 publish
if [ -r "$JANITOR_PSI_FILE" ]; then
FULL60=$(awk '/^full/ {for(i=1;i<=NF;i++) if ($i ~ /^avg60=/) {sub("avg60=","",$i); print $i}}' "$JANITOR_PSI_FILE" 2>/dev/null || echo "")
if [ -n "$FULL60" ] && awk -v v="$FULL60" -v t="$PSI_FULL_AVG60_ALERT" 'BEGIN{exit !(v+0 >= t+0)}'; then
say "⚠ MEMORY PRESSURE psi full/avg60=${FULL60}% >= ${PSI_FULL_AVG60_ALERT}% — too many heavy jobs at once"; STATUS=1
else
say "memory psi full/avg60=${FULL60:-n/a}% OK"
fi
fi
# 4c) concurrency ceiling — alert with a breakdown; the fix is fewer/labelled
# runners (an operator decision), not killing listeners from cron.
ACTIVE=$(pgrep -fc "Runner.Listener" || true)
OMNI=$(pgrep -fc "actions-runner-omniroute[^ ]*/bin[^ ]*/Runner.Listener" || true)
if [ "${ACTIVE:-0}" -gt "$MAX_ACTIVE_RUNNERS" ]; then
say "${ACTIVE} Runner.Listener processes (omniroute=${OMNI:-0}, other=$(( ${ACTIVE:-0} - ${OMNI:-0} ))) > ceiling ${MAX_ACTIVE_RUNNERS} — stop idle extras: systemctl stop <unit> only when it has no Runner.Worker child"
# 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
say "runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} (omniroute=${OMNI:-0}) OK"
echo "[janitor] disk ${USAGE}% OK"
fi
say "done status=$STATUS"
# 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

@@ -60,7 +60,6 @@ import { parse as parseYaml } from "yaml";
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, "..", "..");
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
export const ESLINT_TIMEOUT_MS = 60 * 60 * 1000;
// Per-gate captured output. execFileSync buffers everything and the report only
// shows a one-line summary, so without these files every red requires RE-RUNNING
@@ -180,56 +179,6 @@ export function parseEslintJson(out) {
return null;
}
/**
* Turn one ESLint process result into release-green records.
*
* Keep process failures distinct from report parsing failures. In particular, a timed-out
* ESLint process has no JSON report by definition; collapsing its code-124 diagnostic into
* "could not parse eslint json" hides the actionable cause and sends maintainers debugging
* the parser instead of the gate ceiling.
*/
export function evaluateEslintRun({ code, out }, warningBaseline) {
const parsed = parseEslintJson(out);
if (!parsed) {
return [
{
id: "lint",
label: "ESLint",
kind: "hard",
ok: false,
detail:
code === 0
? "ESLint exited successfully but produced no valid JSON report"
: firstFailureLine(out),
},
];
}
const { errors, warnings } = eslintCounts(parsed);
const warningDrift = isDrift(warnings, warningBaseline);
return [
{
id: "lint-errors",
label: "ESLint errors",
kind: "hard",
ok: errors === 0,
detail: `${errors} error(s)`,
},
{
id: "eslint-warnings",
label: "ESLint warnings (ratchet)",
kind: "drift",
ok: !warningDrift,
detail:
warningBaseline == null
? `${warnings} (no baseline)`
: `${warnings} vs baseline ${warningBaseline}${
warningDrift ? ` (+${warnings - warningBaseline} drift → rebaseline at release)` : ""
}`,
},
];
}
/** Pull the cognitive-complexity violation count from the gate's output. */
export function parseCognitiveCount(out) {
const s = String(out || "");
@@ -502,19 +451,16 @@ async function main() {
// ESLint: ONE pass → errors (hard) + warnings (drift)
{
announce("ESLint (errors + warnings — ~15-45min)");
announce("ESLint (errors + warnings — ~5-15min)");
// Suppressions-aware, matching `npm run lint` (Pacote 4 no-new-warnings): the frozen
// pre-existing debt in config/quality/eslint-suppressions.json must not count as
// errors here — only NET-NEW violations are release reds. The cold release runner can
// exceed 30 minutes as the repository grows, and this pre-flight often runs under load.
const lintRun = run(
// errors here — only NET-NEW violations are release reds. Timeout raised: a full
// repo pass takes ~14min alone and this pre-flight often runs alongside test suites.
const { out } = run(
"npx",
[
"eslint",
".",
"--cache",
"--cache-location",
".eslintcache",
"--format",
"json",
"--suppressions-location",
@@ -525,16 +471,39 @@ async function main() {
// reason alone, which used to mask the real `--format json` report (#7837).
"--pass-on-unpruned-suppressions",
],
// The cold release runner crossed the old 30-minute ceiling as the repository grew,
// then the timeout text was misreported as invalid JSON. Keep a real upper bound, but
// leave enough headroom for the same full-tree walk that completes immediately after it
// under the complexity config on that runner.
{ timeout: ESLINT_TIMEOUT_MS }
{ timeout: 30 * 60 * 1000 }
);
const { out } = lintRun;
saveGateLog("lint", out);
for (const result of evaluateEslintRun(lintRun, baselineValue("eslintWarnings"))) {
record(result);
const parsed = parseEslintJson(out);
if (!parsed) {
record({
id: "lint",
label: "ESLint",
kind: "hard",
ok: false,
detail: "could not parse eslint json",
});
} else {
const { errors, warnings } = eslintCounts(parsed);
record({
id: "lint-errors",
label: "ESLint errors",
kind: "hard",
ok: errors === 0,
detail: `${errors} error(s)`,
});
const base = baselineValue("eslintWarnings");
const over = isDrift(warnings, base);
record({
id: "eslint-warnings",
label: "ESLint warnings (ratchet)",
kind: "drift",
ok: !over,
detail:
base == null
? `${warnings} (no baseline)`
: `${warnings} vs baseline ${base}${over ? ` (+${warnings - base} drift → rebaseline at release)` : ""}`,
});
}
}
@@ -669,8 +638,7 @@ async function main() {
// forever) into a visible failure — survives at 100min.
// Measured on idle .113: unavailable (checkout not found). Tightened to 80min from 100min as a conservative step. TODO: re-measure on idle .113 and tighten to ~1.8× measured.
id: "unit",
label:
"Unit tests (full suite, CI concurrency — ~30-50min idle, up to ~80min under load (awaiting idle .113 measurement, #9532))",
label: "Unit tests (full suite, CI concurrency — ~30-50min idle, up to ~80min under load (awaiting idle .113 measurement, #9532))",
args: ["run", "test:unit:ci"],
timeout: 80 * 60 * 1000,
},

View File

@@ -33,7 +33,7 @@ if (!process.env.DATA_DIR) {
// Best-effort cleanup so a long suite run does not leak hundreds of temp DBs.
process.on("exit", () => {
try {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// ignore — the OS reaps its temp dir eventually.
}

View File

@@ -92,7 +92,7 @@ describe("Protocol clients E2E", () => {
method: "PATCH",
body: JSON.stringify({ a2aEnabled: true }),
});
expect([200, 401, 403]).toContain(response.status);
expect([200, 401]).toContain(response.status);
});
it(
@@ -134,7 +134,7 @@ describe("Protocol clients E2E", () => {
}
const auditRes = await apiFetch("/api/mcp/audit?limit=50&tool=omniroute_get_health");
expect([200, 401, 403]).toContain(auditRes.status);
expect([200, 401]).toContain(auditRes.status);
if (auditRes.status === 200) {
expect(auditRes.ok).toBe(true);
const auditJson = (await auditRes.json()) as any;

View File

@@ -366,7 +366,7 @@ test.after(async () => {
await serverA.stop();
await serverB.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
});
test("primary healthy: request routes to Server A only", async () => {

View File

@@ -286,7 +286,7 @@ export async function createChatPipelineHarness(prefix) {
clearSkillState();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(testDataDir, { recursive: true, force: true });
fs.mkdirSync(testDataDir, { recursive: true });
initTranslators();
}
@@ -300,7 +300,7 @@ export async function createChatPipelineHarness(prefix) {
clearSkillState();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(testDataDir, { recursive: true, force: true });
}
async function seedConnection(provider: string, overrides: SeedConnectionOverrides = {}) {

View File

@@ -24,7 +24,7 @@ const DEFAULT_PATTERNS = [".bank.", ".gov.", "okta.com", "auth0.com"];
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -34,7 +34,7 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* noop */ }
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
});
// ── POST patterns ──────────────────────────────────────────────────────────

View File

@@ -30,7 +30,7 @@ function certFilePath() {
}
function resetCertDir() {
fs.rmSync(certDir(), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(certDir(), { recursive: true, force: true });
fs.mkdirSync(certDir(), { recursive: true });
}
@@ -39,7 +39,7 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* noop */ }
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
});
// ── GET /cert ─────────────────────────────────────────────────────────────

View File

@@ -24,7 +24,7 @@ const mappingsRoute = await import(
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -33,7 +33,7 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* noop */ }
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
});
// ── GET (empty) ────────────────────────────────────────────────────────────

View File

@@ -39,7 +39,7 @@ const routeGuard = await import("../../src/server/authz/routeGuard.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -49,7 +49,7 @@ test.beforeEach(() => {
test.after(() => {
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
/* noop */
}

View File

@@ -39,7 +39,7 @@ async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -55,7 +55,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Auth tests ────────────────────────────────────────────────────────────────
@@ -288,6 +288,6 @@ test("grok-build status uses GROK_HOME and returns its managed endpoint", async
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(grokHome, { recursive: true, force: true });
}
});

View File

@@ -32,7 +32,7 @@ const { clearAntigravityProjectCache } = await import(
test.after(() => {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
});

View File

@@ -25,7 +25,7 @@ async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -63,7 +63,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("API keys routes require management auth when login protection is enabled", async () => {

View File

@@ -24,7 +24,7 @@ async function resetStorage() {
delete process.env.ENABLE_SOCKS5_PROXY;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -59,7 +59,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("critical routes: v1 management proxies covers auth, lookup, where-used, patch, and delete branches", async () => {

View File

@@ -20,7 +20,7 @@ const auditRoute = await import("../../src/app/api/compliance/audit-log/route.ts
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -38,7 +38,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
/**

View File

@@ -268,7 +268,7 @@ async function stopProcess(child: ReturnType<typeof spawn>) {
async function removeDirWithRetry(dir: string) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(dir, { recursive: true, force: true });
return;
} catch (error) {
if (attempt === 4) throw error;

View File

@@ -373,7 +373,7 @@ async function resetStorage() {
invalidateMemorySettingsCache();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
initTranslators();
}
@@ -512,7 +512,7 @@ test.after(async () => {
clearInflight();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => {

View File

@@ -28,7 +28,7 @@ async function resetStorage() {
readCacheDb.invalidateDbCache();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -40,7 +40,7 @@ test.after(async () => {
globalThis.fetch = originalFetch;
core.closeDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {}
});

View File

@@ -21,7 +21,7 @@ test.after(async () => {
globalThis.fetch = originalFetch;
core.closeDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {}
});

View File

@@ -27,7 +27,7 @@ const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/codewhal
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -126,7 +126,7 @@ test("codewhale-settings POST: writes primary ~/.codewhale/config.toml for a fre
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -173,7 +173,7 @@ test("codewhale-settings POST: syncs an existing legacy ~/.deepseek/config.toml"
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -201,7 +201,7 @@ test("codewhale-settings GET: falls back to legacy ~/.deepseek/config.toml when
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -238,7 +238,7 @@ test("codewhale-settings DELETE: removes primary and legacy config files", async
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -272,7 +272,7 @@ test("codewhale-settings route.ts: does not call exec() or spawn() directly", ()
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -25,7 +25,7 @@ const { GET, POST, DELETE } = await import(
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -120,7 +120,7 @@ test("deepseek-tui-settings POST: writes config.toml with valid body", async ()
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -152,7 +152,7 @@ test("deepseek-tui-settings DELETE: removes config file", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -186,7 +186,7 @@ test("deepseek-tui-settings route.ts: does not call exec() or spawn() directly",
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -26,7 +26,7 @@ const { GET, POST, DELETE } = await import(
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -126,7 +126,7 @@ test("forge-settings POST: writes config.toml with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -160,7 +160,7 @@ test("forge-settings DELETE: removes config file when it exists", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -194,7 +194,7 @@ test("forge-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -39,7 +39,7 @@ const { GET, POST, DELETE } =
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -163,7 +163,7 @@ test("grok-build-settings POST: writes [model.omniroute] section and preserves e
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -219,7 +219,7 @@ test("grok-build-settings DELETE: removes our section, preserves the rest, resto
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -237,7 +237,7 @@ test("grok-build-settings DELETE: no-op success when no config file exists", asy
assert.equal(body.success, true);
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -289,7 +289,7 @@ test("grok-build-settings: honors GROK_HOME and rejects a relative value", async
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(grokHome, { recursive: true, force: true });
}
});
@@ -313,7 +313,7 @@ test("grok-build-settings POST: returns 409 for an unowned omniroute slot", asyn
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(grokHome, { recursive: true, force: true });
}
});
@@ -342,7 +342,7 @@ test("grok-build-settings POST: resolves keyId to an unmasked key", async () =>
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(grokHome, { recursive: true, force: true });
}
});
@@ -376,7 +376,7 @@ test("grok-build-settings route.ts: does not call exec() or spawn() directly", (
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -23,7 +23,7 @@ const { GET, POST, DELETE } = await import(
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -118,7 +118,7 @@ test("jcode-settings POST: writes config.json with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -155,7 +155,7 @@ test("jcode-settings DELETE: removes OmniRoute fields from existing config", asy
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -189,7 +189,7 @@ test("jcode-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -40,7 +40,7 @@ function req(init?: RequestInit) {
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -58,7 +58,7 @@ test.beforeEach(async () => {
test.afterEach(() => {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
});
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
@@ -189,7 +189,7 @@ test("letta-settings: error responses do not leak stack traces", async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -59,7 +59,7 @@ function seedOmpDb() {
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -77,7 +77,7 @@ test.beforeEach(async () => {
test.afterEach(() => {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
});
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
@@ -190,7 +190,7 @@ test("omp-settings: error responses do not leak stack traces", async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -23,7 +23,7 @@ const { GET, POST, DELETE } = await import(
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -118,7 +118,7 @@ test("pi-settings POST: writes config.json with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -155,7 +155,7 @@ test("pi-settings DELETE: removes OmniRoute fields from existing config", async
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -189,7 +189,7 @@ test("pi-settings route.ts: does not call exec() or spawn() directly", () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -23,7 +23,7 @@ const { GET, POST, DELETE } = await import(
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -118,7 +118,7 @@ test("smelt-settings POST: writes config.json with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -155,7 +155,7 @@ test("smelt-settings DELETE: removes OmniRoute fields from existing config", asy
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(tmpHome, { recursive: true, force: true });
}
});
@@ -189,7 +189,7 @@ test("smelt-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -44,6 +44,6 @@ test("Codex Spark cooldown survives a fresh process without creating child conne
assert.equal(after.connectionId, before.connectionId);
assert.deepEqual(after.upstreamModels, ["gpt-5.5"]);
} finally {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
});

View File

@@ -305,6 +305,6 @@ test("chat completions streams Codex Responses reasoning through real route HTTP
globalThis.fetch = originalFetch;
if (routeServer) await closeServer(routeServer);
core.closeDbInstance({ checkpointMode: null });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
}
});

View File

@@ -146,12 +146,12 @@ export async function createLiveHarness(prefix: string): Promise<LiveHarness> {
}
}
} catch (err: any) {
fs.rmSync(snapshotDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(snapshotDir, { recursive: true, force: true });
throw new Error(`[liveHarness] Failed to fetch VPS secrets via ssh: ${err.message}`);
}
if (!storageEncryptionKey || !apiKeySecret) {
fs.rmSync(snapshotDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(snapshotDir, { recursive: true, force: true });
throw new Error(
"[liveHarness] Could not parse STORAGE_ENCRYPTION_KEY or API_KEY_SECRET from VPS .env"
);
@@ -182,7 +182,7 @@ export async function createLiveHarness(prefix: string): Promise<LiveHarness> {
{ timeout: 60_000 }
);
} catch (err: any) {
fs.rmSync(snapshotDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(snapshotDir, { recursive: true, force: true });
throw new Error(`[liveHarness] Failed to scp production DB: ${err.message}`);
}
@@ -400,7 +400,7 @@ export async function createLiveHarness(prefix: string): Promise<LiveHarness> {
resetAllCircuitBreakers();
core.resetDbInstance();
// Destroy the snapshot — targets only the temp dir, NEVER /root/.omniroute.
fs.rmSync(snapshotDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(snapshotDir, { recursive: true, force: true });
}
// Populate the map eagerly so servedProvider (sync) works right after

View File

@@ -280,7 +280,7 @@ test.after(async () => {
if (app) await stopProcess(app.child);
await upstream.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──────────────────────────────────────────────────────────────────

View File

@@ -14,7 +14,7 @@ const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
test.after(() => {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
});

View File

@@ -88,7 +88,7 @@ test.afterEach(() => {
clearInflight();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
});

View File

@@ -31,7 +31,7 @@ const { GET } = embeddingProvidersRoute;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -44,7 +44,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -31,7 +31,7 @@ const { GET } = engineStatusRoute;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -44,7 +44,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -34,7 +34,7 @@ const { POST } = reindexRoute;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -66,7 +66,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -33,7 +33,7 @@ const { POST } = retrieveRoute;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -53,7 +53,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -33,7 +33,7 @@ const { createMemory, getMemory } = memoryStore;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -69,7 +69,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -33,7 +33,7 @@ const { POST } = summarizeRoute;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -75,7 +75,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Tests ──

View File

@@ -19,7 +19,7 @@ const healthRoute = await import("../../src/app/api/health/ping/route.ts");
async function resetStorage() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
modelsCatalog.__resetCatalogBuilderRunsForTest();
}
@@ -30,7 +30,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test(

View File

@@ -23,7 +23,7 @@ after(() => {
globalThis.fetch = originalFetch;
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
fs.rmSync(testHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(testHome, { recursive: true, force: true });
});
function runOpencode(binary: string, args: string[]) {

View File

@@ -226,7 +226,7 @@ describe("Performance: memory API route handler (1000 records)", () => {
db.prepare("DELETE FROM memories WHERE api_key_id = ?").run(TEST_API_KEY_ID);
// Final cleanup: reset DB instance and remove temp dir
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
it(`should handle GET /api/memory?limit=50 in <${THRESHOLD_API_ROUTE_MS}ms`, async () => {

View File

@@ -61,7 +61,7 @@ function postRequest(body: unknown): Request {
// ─── Cleanup ─────────────────────────────────────────────────────────────────
test.after(() => {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── OPTIONS ─────────────────────────────────────────────────────────────────

View File

@@ -86,7 +86,7 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── OPTIONS ─────────────────────────────────────────────────────────────────

View File

@@ -83,7 +83,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ─── POST validation ─────────────────────────────────────────────────────────

View File

@@ -57,7 +57,7 @@ function writeTestPlugin(opts?: { name?: string; onRequest?: boolean; enabledByD
// ── Helpers ──
function cleanupDir(dir: string) {
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(dir, { recursive: true, force: true });
}
// Track temp source dirs for cleanup
@@ -65,7 +65,7 @@ const activeSourceDirs: string[] = [];
function cleanupSourceDirs() {
for (const dir of activeSourceDirs) {
try { fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch {}
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
activeSourceDirs.length = 0;
}

View File

@@ -118,7 +118,7 @@ async function fetchCatalog(
test.before(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
// requireLogin + requireAuthForModels ON so the API-key surface is gated.
await localDb.updateSettings({ requireLogin: true, requireAuthForModels: true, password: "" });
@@ -127,7 +127,7 @@ test.before(async () => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test.describe("provider journey — in-process contract (#8330)", () => {

View File

@@ -23,13 +23,13 @@ const proxyLogger = await import("../../src/lib/proxyLogger.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.after(async () => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("integration: proxy create with inline assignment is atomic and clears legacy config", async () => {

View File

@@ -45,7 +45,7 @@ const asNextRequest = (req: Request) => req as unknown as import("next/server").
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
// #5597 follow-up: the memory-settings cache is a module-level singleton that
// survives per-test DB resets — bust it so each test starts from a clean read.
@@ -88,7 +88,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ── Settings GET ──

View File

@@ -35,7 +35,7 @@ async function enableManagementAuth() {
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -46,7 +46,7 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------

View File

@@ -36,7 +36,7 @@ type Db = {
function resetDb() {
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -95,7 +95,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("DELETE pool waits for scoped quota-combo cleanup before returning 204", async () => {

View File

@@ -35,7 +35,7 @@ const usageRoute = await import("../../src/app/api/quota/pools/[id]/usage/route.
function resetDb() {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -46,7 +46,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GET /usage surfaces catalog dimensions for a catalog-only pool (provider resolved from connection)", async () => {

View File

@@ -38,7 +38,7 @@ async function enableManagementAuth() {
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -49,7 +49,7 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------

View File

@@ -42,7 +42,7 @@ async function enableManagementAuth() {
function resetDb() {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -54,7 +54,7 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GET /api/quota/pools/[id]/usage without auth → 401", async () => {

View File

@@ -40,7 +40,7 @@ async function enableManagementAuth() {
function resetDb() {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -52,7 +52,7 @@ test.beforeEach(async () => {
test.after(() => {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("GET /api/quota/preview without auth → 401", async () => {

View File

@@ -42,7 +42,7 @@ const settingsRoute = await import("../../src/app/api/settings/quota-store/route
function resetDb() {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -96,7 +96,7 @@ test.after(() => {
core.resetDbInstance();
resetQuotaStoreSingleton();
delete process.env.QUOTA_STORE_REDIS_URL;
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------

View File

@@ -41,7 +41,7 @@ function resetDb() {
resetQuotaStoreSingleton();
delete process.env.QUOTA_STORE_REDIS_URL;
delete process.env.INITIAL_PASSWORD;
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -53,7 +53,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
resetQuotaStoreSingleton();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------

View File

@@ -547,7 +547,7 @@ test.after(async () => {
}
await relay.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
});
test("resilience API only exposes configuration, not runtime breaker state", async () => {

View File

@@ -102,7 +102,7 @@ async function seedRateLimitedConnection(provider: string) {
/** Reset DB state between tests. */
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -116,7 +116,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
// ---------------------------------------------------------------------------

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