mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
Compare commits
1 Commits
release/v3
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed34d16dc9 |
52
.agents/skills/capture-release-evidences/SKILL.md
Normal file
52
.agents/skills/capture-release-evidences/SKILL.md
Normal file
@@ -0,0 +1,52 @@
|
||||
---
|
||||
name: capture-release-evidences-cx
|
||||
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
|
||||
---
|
||||
|
||||
# Capture Release Evidences Workflow
|
||||
|
||||
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
|
||||
|
||||
> **Tool mapping note (v3.8):** The `browser_subagent` tool referenced below is specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps remain the same regardless of the browser-automation surface in use.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
|
||||
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### 1. Identify Target Features
|
||||
|
||||
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
|
||||
|
||||
- **CLI Tools Settings**
|
||||
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
|
||||
- **New Feature Modals**
|
||||
|
||||
### 2. Run the Browser Subagent
|
||||
|
||||
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
|
||||
**Important Task Guidelines for the Subagent:**
|
||||
|
||||
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
|
||||
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
|
||||
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
|
||||
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
|
||||
|
||||
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
|
||||
|
||||
### 3. Generate Report Artifact
|
||||
|
||||
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `` syntax.
|
||||
|
||||
### Example Invocation
|
||||
|
||||
\```json
|
||||
{
|
||||
"TaskName": "Validating Qoder PAT Configuration UI",
|
||||
"TaskSummary": "Validates the Qoder provider configuration modal",
|
||||
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
|
||||
"RecordingName": "qoder_pat_ui_validation"
|
||||
}
|
||||
\```
|
||||
45
.agents/skills/deploy-vps-akamai/SKILL.md
Normal file
45
.agents/skills/deploy-vps-akamai/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: deploy-vps-akamai-cx
|
||||
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
|
||||
---
|
||||
|
||||
# Deploy to Akamai VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands. Do not parallelize dependent build, copy, install, restart, and verification steps.
|
||||
- Report each remote result explicitly before finishing.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Akamai VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
```
|
||||
56
.agents/skills/deploy-vps-both/SKILL.md
Normal file
56
.agents/skills/deploy-vps-both/SKILL.md
Normal file
@@ -0,0 +1,56 @@
|
||||
---
|
||||
name: deploy-vps-both-cx
|
||||
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
|
||||
---
|
||||
|
||||
# Deploy to VPS (Both) Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands.
|
||||
- Build/package once first. After the artifact exists, copy/install/verify on Akamai and Local may run in parallel if they do not depend on each other.
|
||||
- Report each VPS result explicitly before finishing.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
45
.agents/skills/deploy-vps-local/SKILL.md
Normal file
45
.agents/skills/deploy-vps-local/SKILL.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
name: deploy-vps-local-cx
|
||||
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
|
||||
---
|
||||
|
||||
# Deploy to Local VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` only for independent commands. Do not parallelize dependent build, copy, install, restart, and verification steps.
|
||||
- Report each remote result explicitly before finishing.
|
||||
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Local VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
368
.agents/skills/generate-release/SKILL.md
Normal file
368
.agents/skills/generate-release/SKILL.md
Normal file
@@ -0,0 +1,368 @@
|
||||
---
|
||||
name: generate-release-cx
|
||||
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
|
||||
---
|
||||
|
||||
# Generate Release Workflow
|
||||
|
||||
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
|
||||
- When the workflow says `notify_user` or `BlockedOnUser: true`, present the report/status in the final response and stop. Do not continue into the next phase until the user explicitly approves.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
|
||||
|
||||
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Two-Phase Flow
|
||||
|
||||
```
|
||||
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
|
||||
↕ 🛑 STOP: Notify user, wait for PR confirmation
|
||||
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
|
||||
```
|
||||
|
||||
**NEVER push directly to main or create tags before the user confirms the PR.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Security Verification (MANDATORY)
|
||||
|
||||
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
|
||||
|
||||
1. **Run Local Dependencies Audit:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
```
|
||||
|
||||
_Fix any `high` or `critical` vulnerabilities identified._
|
||||
|
||||
2. **Check GitHub CodeQL & Dependabot Alerts:**
|
||||
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Merge
|
||||
|
||||
### 1. Create release branch
|
||||
|
||||
```bash
|
||||
git checkout -b release/v3.x.y
|
||||
```
|
||||
|
||||
### 2. Determine and sync version
|
||||
|
||||
Check current version in `package.json`:
|
||||
|
||||
```bash
|
||||
grep '"version"' package.json
|
||||
```
|
||||
|
||||
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
|
||||
>
|
||||
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
|
||||
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
|
||||
> `npm version patch --no-git-tag-version`
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
>
|
||||
> 1. `npm version patch --no-git-tag-version` ← bump first
|
||||
> 2. implement features / fix bugs
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **OR if features are already staged:**
|
||||
>
|
||||
> 1. implement features (do NOT commit yet)
|
||||
> 2. `npm version patch --no-git-tag-version` ← bump before committing
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **NEVER do this (creates version mismatch in git history):**
|
||||
>
|
||||
> - ~~commit features → then bump version → commit package.json separately~~
|
||||
>
|
||||
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 3. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Finalize CHANGELOG.md
|
||||
|
||||
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
|
||||
|
||||
Replace the `[Unreleased]` header with the new version and date.
|
||||
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] — 2026-04-19
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- ...
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- ...
|
||||
|
||||
---
|
||||
|
||||
## [3.6.9] — 2026-04-19
|
||||
```
|
||||
|
||||
### 5. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ openapi.yaml → $VERSION"
|
||||
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
# Re-run install to assert the workspace lockfile is updated
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Update README.md and i18n docs
|
||||
|
||||
Manually perform these documentation updates (there is no `/update-docs` workflow — it was deprecated in v3.8):
|
||||
|
||||
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
|
||||
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
|
||||
- Update the relevant `docs/<AREA>.md` if architecture or counts changed
|
||||
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
|
||||
|
||||
### 7. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests must pass before creating the PR.
|
||||
|
||||
### 8. Stage, commit, and push
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(release): v3.x.y — summary of changes"
|
||||
git push origin release/v3.x.y
|
||||
```
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extract the exact changelog entry for this version from the root CHANGELOG.md
|
||||
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
|
||||
|
||||
# Append test status and next steps
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### Tests" >> /tmp/changelog_body.txt
|
||||
echo "- All tests pass" >> /tmp/changelog_body.txt
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
|
||||
|
||||
gh pr create \
|
||||
--repo diegosouzapw/OmniRoute \
|
||||
--base main \
|
||||
--head release/v$VERSION \
|
||||
--title "Release v$VERSION" \
|
||||
--body-file /tmp/changelog_body.txt
|
||||
```
|
||||
|
||||
### 10. 🛑 STOP — Notify User & Await PR Confirmation
|
||||
|
||||
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
|
||||
|
||||
Inform the user:
|
||||
|
||||
- PR URL
|
||||
- Summary of changes
|
||||
- Test results
|
||||
- List of files changed
|
||||
|
||||
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post-Merge Validation (Local VPS)
|
||||
|
||||
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
|
||||
|
||||
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
|
||||
|
||||
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Build and pack locally
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
|
||||
# Deploy to LOCAL VPS (192.168.0.15)
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
|
||||
```
|
||||
|
||||
### 12. 🛑 STOP — Notify User & Await Final OK
|
||||
|
||||
**This is a mandatory stop point.**
|
||||
Inform the user that the `main` branch is now running on the Local VPS.
|
||||
Wait for the user to manually test and give the **OK**.
|
||||
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Official Launch
|
||||
|
||||
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
|
||||
|
||||
### 13. Create Git Tag and GitHub Release (MANDATORY)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extracts the changelog section for this version
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
|
||||
|
||||
git tag -a "v$VERSION" -m "Release v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
|
||||
```
|
||||
|
||||
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
|
||||
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
|
||||
> After pushing the tag in step 13, **verify the workflow runs**:
|
||||
|
||||
```bash
|
||||
# Verify the Docker workflow triggered
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
|
||||
|
||||
# Wait for the Docker build to complete (usually 5–10 min)
|
||||
gh run watch --repo diegosouzapw/OmniRoute
|
||||
```
|
||||
|
||||
### 15. Publish to NPM (Optional/Automated)
|
||||
|
||||
Normally handled by CI, but if manual publish is required:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 16. Deploy to AKAMAI VPS (Production)
|
||||
|
||||
Now that the release is officially cut, deploy it to the Akamai VPS.
|
||||
|
||||
```bash
|
||||
# Deploy to AKAMAI VPS (69.164.221.35)
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
|
||||
```
|
||||
|
||||
## Phase 4: Release Monitoring & Artifact Validation
|
||||
|
||||
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
|
||||
|
||||
### 18. Monitor CI Pipelines
|
||||
|
||||
Wait for and verify the successful completion of the following automated jobs:
|
||||
|
||||
1. **Docker Hub Publish**
|
||||
2. **Electron Build**
|
||||
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
|
||||
|
||||
```bash
|
||||
# Monitor Docker Hub workflow
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Monitor Electron build
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Verify NPM version
|
||||
npm info omniroute version
|
||||
```
|
||||
|
||||
### 19. Handle Failures (If Any)
|
||||
|
||||
If a workflow fails:
|
||||
|
||||
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
|
||||
- Apply the fix on the `main` branch.
|
||||
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
|
||||
|
||||
### 20. Preserve release branch
|
||||
|
||||
```bash
|
||||
# Branch is kept for historical purposes. Do not delete.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` workflow anymore)
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
51
.agents/skills/issue-triage/SKILL.md
Normal file
51
.agents/skills/issue-triage/SKILL.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
name: issue-triage-cx
|
||||
description: How to respond to GitHub issues with insufficient information
|
||||
---
|
||||
|
||||
# Issue Triage Workflow
|
||||
|
||||
Respond to GitHub issues that need more information before they can be investigated.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify issues needing triage
|
||||
|
||||
```bash
|
||||
gh issue list --state open --limit 20
|
||||
```
|
||||
|
||||
### 2. Evaluate each issue
|
||||
|
||||
Check if the issue has:
|
||||
|
||||
- Clear reproduction steps
|
||||
- Environment details (OS, Node.js version, OmniRoute version)
|
||||
- Error logs/screenshots
|
||||
- Expected vs actual behavior
|
||||
|
||||
### 3. Respond with triage template
|
||||
|
||||
For issues missing information:
|
||||
|
||||
```markdown
|
||||
Thank you for reporting this issue! To help us investigate, please provide:
|
||||
|
||||
1. **OmniRoute version**: (`omniroute --version`)
|
||||
2. **Node.js version**: (`node --version`)
|
||||
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
|
||||
4. **Installation method**: (npm, Docker, source)
|
||||
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
|
||||
6. **Error logs**: (paste relevant logs from the console)
|
||||
7. **Expected behavior**: (what should happen)
|
||||
|
||||
This will help us debug and resolve your issue faster. 🙏
|
||||
```
|
||||
|
||||
### 4. Label the issue
|
||||
|
||||
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
|
||||
|
||||
```bash
|
||||
gh issue edit <NUMBER> --add-label "needs-info"
|
||||
```
|
||||
173
.agents/skills/resolve-issues/SKILL.md
Normal file
173
.agents/skills/resolve-issues/SKILL.md
Normal file
@@ -0,0 +1,173 @@
|
||||
---
|
||||
name: resolve-issues-cx
|
||||
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
|
||||
---
|
||||
|
||||
# /resolve-issues — Automated Issue Resolution Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
|
||||
- The initial report/plan is a hard stop. Do not edit code, close issues, or commit until the user explicitly approves the report.
|
||||
- Keep classification and bug analysis bounded enough to produce the user-facing report before deep implementation work.
|
||||
|
||||
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 1–5.
|
||||
|
||||
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
// turbo
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch All Open Issues
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
|
||||
|
||||
**Step 3a — Get Issue numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one issue number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
|
||||
|
||||
- For each issue number from step 3a, run:
|
||||
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
- Sort by oldest first (FIFO).
|
||||
|
||||
### 4. Classify Each Issue
|
||||
|
||||
For each issue, determine its type:
|
||||
|
||||
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
|
||||
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
|
||||
- **Question** — Has `question` label, or is asking "how to" something
|
||||
- **Other** — Anything else
|
||||
|
||||
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
|
||||
|
||||
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
|
||||
|
||||
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
|
||||
|
||||
#### 5a. Understand the Problem
|
||||
|
||||
For each bug issue, perform the full analysis:
|
||||
|
||||
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
|
||||
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
|
||||
- Whether someone already responded with a fix
|
||||
- Whether a community member confirmed the issue is resolved
|
||||
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
|
||||
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
|
||||
|
||||
#### 5b. Check Information Sufficiency
|
||||
|
||||
Verify the issue contains enough to act on:
|
||||
|
||||
- [ ] Clear description of the problem
|
||||
- [ ] Steps to reproduce OR error logs
|
||||
- [ ] Provider/model/version information
|
||||
- [ ] Expected vs actual behavior
|
||||
|
||||
#### 5c. Determine Issue Disposition
|
||||
|
||||
For each bug, classify into one of 5 actions:
|
||||
|
||||
| Disposition | When to Apply | Action |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
|
||||
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
|
||||
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
|
||||
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
|
||||
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
|
||||
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
|
||||
|
||||
#### 5d. For "FIX — Code Change" Issues
|
||||
|
||||
Before coding, perform deep source analysis to formulate a plan:
|
||||
|
||||
1. **Search the codebase** — `grep_search` for error strings, relevant function names, affected files
|
||||
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
|
||||
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
|
||||
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
|
||||
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
|
||||
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
|
||||
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
|
||||
|
||||
#### 5e. For "RESPOND" Issues
|
||||
|
||||
Post a substantive comment that:
|
||||
|
||||
- Acknowledges the specific error they reported
|
||||
- Explains the likely root cause
|
||||
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
|
||||
- Asks for follow-up info if needed
|
||||
|
||||
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
|
||||
|
||||
### 6. Generate Report & Wait for Validation
|
||||
|
||||
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
|
||||
|
||||
| Issue | Title | Status | Proposed Action / Version |
|
||||
| ----- | ----- | ------------- | ----------------------------------------- |
|
||||
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
|
||||
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
|
||||
| #N | Title | 📝 Respond | Guidance comment to be posted |
|
||||
| #N | Title | ❓ Needs Info | Triage comment to be posted |
|
||||
| #N | Title | ⏭️ Skip | Feature request / not a bug |
|
||||
|
||||
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
|
||||
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
|
||||
|
||||
- If the user says **OK** or approves → Proceed to step 7
|
||||
- If the user requests changes → Adjust the proposed solution and present the report again
|
||||
- If the user rejects → Revert any accidental changes and stop
|
||||
|
||||
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
|
||||
|
||||
After the user validates and gives the OK:
|
||||
|
||||
1. **Implement the fixes** — modify the codebase according to the approved plan.
|
||||
2. **Run tests** — `npm run test:all` (or the specific test file) to ensure 100% pass.
|
||||
3. **Update CHANGELOG.md** with all new bug fix entries.
|
||||
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
|
||||
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
|
||||
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
|
||||
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
|
||||
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
|
||||
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user).
|
||||
|
||||
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.
|
||||
126
.agents/skills/review-discussions/SKILL.md
Normal file
126
.agents/skills/review-discussions/SKILL.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
name: review-discussions-cx
|
||||
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
|
||||
---
|
||||
|
||||
# /review-discussions — GitHub Discussions Review & Response Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
|
||||
|
||||
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent`, modern runtimes should substitute with the `gh` CLI — `gh api graphql` for reading discussions and mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads and GitHub/browser fetches.
|
||||
- The summary report is a hard stop. Do not post discussion replies or create issues until the user explicitly approves.
|
||||
|
||||
// turbo-all
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Fetch All Open Discussions
|
||||
|
||||
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
|
||||
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
|
||||
- For each discussion, fetch the individual page to read the full content and all comments/replies
|
||||
|
||||
### 3. Summarize All Discussions
|
||||
|
||||
For each discussion, extract:
|
||||
|
||||
- **Title** and **#Number**
|
||||
- **Author** (GitHub username)
|
||||
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
|
||||
- **Date** created
|
||||
- **Summary** of the original post (1-2 sentences)
|
||||
- **Comments count** and key participants
|
||||
- **Your previous response** (if any)
|
||||
- **Pending action** — whether a response or follow-up is needed
|
||||
|
||||
### 4. Present Summary Report to User
|
||||
|
||||
Present the full summary to the user organized by category, using a table:
|
||||
|
||||
| # | Category | Title | Author | Date | Status |
|
||||
| --- | -------- | ----- | ------ | ------ | ----------------- |
|
||||
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
|
||||
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
|
||||
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
|
||||
|
||||
Highlight:
|
||||
|
||||
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
|
||||
- **✅ Answered** — Maintainer already responded
|
||||
- **🐛 Bug reported** — A bug was mentioned that needs tracking
|
||||
- **💡 Actionable** — Contains a concrete feature request that could become an issue
|
||||
|
||||
### 5. Draft & Post Responses
|
||||
|
||||
For each discussion that needs a response, draft a reply following these guidelines:
|
||||
|
||||
#### Response Style
|
||||
|
||||
- **Friendly and professional** — Start with "Hey @username!"
|
||||
- **Acknowledge the contribution** — Thank the user for their input
|
||||
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
|
||||
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
|
||||
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
|
||||
- **Keep it concise** — 3-5 paragraphs max
|
||||
|
||||
#### Posting via Browser
|
||||
|
||||
- Use `browser_subagent` to navigate to each discussion and post the comment
|
||||
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
|
||||
- Use regular hyphens `-` instead of em-dashes
|
||||
- Use `->` instead of arrow symbols
|
||||
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
|
||||
- Use `**bold**` and `\`code\`` markdown formatting
|
||||
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
|
||||
- Verify the comment was posted by checking the page shows the new comment
|
||||
|
||||
### 6. Create Issues from Actionable Feature Requests
|
||||
|
||||
For discussions that contain concrete, actionable feature requests:
|
||||
|
||||
1. Ask the user which ones should become issues
|
||||
2. For each approved request, create a GitHub issue via `browser_subagent`:
|
||||
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
|
||||
- **Title**: `<Feature Name> - <Short description>`
|
||||
- **Body** should include:
|
||||
- `## Feature Request` header
|
||||
- `**Source:** Discussion #N by @author`
|
||||
- `## Problem` — What limitation the user hit
|
||||
- `## Proposed Solution` — How it could work
|
||||
- `### Implementation Ideas` — Technical approach
|
||||
- `### Current Workarounds` — What users can do today
|
||||
- `## Additional Context` — Links to related issues/discussions
|
||||
- Add `enhancement` label
|
||||
- Click "Submit new issue" / "Create"
|
||||
3. After creation, go back to the original discussion and post a comment linking to the new issue:
|
||||
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
|
||||
|
||||
### 7. Final Report
|
||||
|
||||
Present a final summary to the user:
|
||||
|
||||
| Discussion | Action Taken |
|
||||
| ---------- | ---------------------------------- |
|
||||
| #N — Title | Responded with workarounds |
|
||||
| #N — Title | Responded + created Issue #N |
|
||||
| #N — Title | Already answered, no action needed |
|
||||
| #N — Title | Responded to follow-up comment |
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
|
||||
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
|
||||
- For discussions in non-English languages, respond in the same language as the original post
|
||||
- Always reference specific dashboard paths, config options, or code files when explaining existing features
|
||||
- When a discussion reveals a bug, note it separately from feature requests
|
||||
268
.agents/skills/review-prs/SKILL.md
Normal file
268
.agents/skills/review-prs/SKILL.md
Normal file
@@ -0,0 +1,268 @@
|
||||
---
|
||||
name: review-prs-cx
|
||||
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
|
||||
---
|
||||
|
||||
# /review-prs — PR Review & Analysis Workflow
|
||||
|
||||
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
|
||||
|
||||
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
|
||||
>
|
||||
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
|
||||
>
|
||||
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
|
||||
>
|
||||
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
|
||||
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
|
||||
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
|
||||
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
|
||||
>
|
||||
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
|
||||
>
|
||||
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
|
||||
>
|
||||
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
|
||||
|
||||
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
The source Claude command uses `// turbo` and `// turbo-all` as execution hints. In Codex, treat them explicitly as follows:
|
||||
|
||||
- `// turbo`: batch independent local reads and small `gh`/`git` calls with `multi_tool_use.parallel`.
|
||||
- `// turbo-all`: fan out independent per-PR/per-issue calls in practical batches, usually up to 4 GitHub calls at a time.
|
||||
- Do not expand Step 4 into exhaustive CI-log debugging before Step 6. Fetch numbers, metadata, diffs/review comments, quick merge/conflict status, and only inspect extra logs when they directly affect the verdict.
|
||||
- Step 6 is a hard stop. In Codex, present the report in the final response and wait for the user before Step 7/8.
|
||||
- Do not checkout PR branches, edit files, post PR comments, close PRs, merge, cherry-pick, or run broad fix/test loops until the user explicitly approves the report.
|
||||
- If `gh pr diff` is too large, record the limit and use `gh pr view --json files` plus `git fetch refs/pull/...` with `git diff --stat` / `git diff --name-status`; only read targeted hunks needed for confirmed findings.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Read `package.json` to get the repository URL, or use the git remote origin URL
|
||||
// turbo
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
# Bump patch: e.g. 3.3.11 → 3.3.12
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch Open Pull Requests
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
|
||||
|
||||
**Step 3a — Get PR numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one PR number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each PR** (one call per PR):
|
||||
|
||||
- For each PR number from step 3a, run:
|
||||
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
|
||||
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
|
||||
|
||||
- For each PR number, run:
|
||||
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
|
||||
- Then read each diff file with the appropriate file-read tool (`Read` in Claude Code; equivalent in your agent runtime).
|
||||
|
||||
- For each open PR, collect:
|
||||
- PR number, title, author, branch, number of commits, date
|
||||
- PR description/body
|
||||
- Files changed (diff)
|
||||
- Existing review comments (from bots or humans)
|
||||
|
||||
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
|
||||
|
||||
### 3.5 Redirect PR Base Branches to Release Branch
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
|
||||
|
||||
```bash
|
||||
# Get the current release branch name
|
||||
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
|
||||
|
||||
# For each open PR that targets main, change its base to the release branch
|
||||
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
|
||||
echo "Redirecting PR #$PR_NUM → $RELEASE_BRANCH"
|
||||
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
|
||||
done
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
1. PRs merge into the release branch, not directly into `main`
|
||||
2. Merge conflict detection is accurate against the release branch
|
||||
3. The release branch accumulates all changes before the final merge to `main`
|
||||
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
|
||||
|
||||
### 4. Analyze Each PR — For each open PR, perform the following analysis:
|
||||
|
||||
#### 4a. Feature Assessment
|
||||
|
||||
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
|
||||
- **Alignment** — Check if it aligns with the project's architecture and roadmap
|
||||
- **Complexity** — Assess if the scope is reasonable or if it should be split
|
||||
|
||||
#### 4b. Code Quality Review
|
||||
|
||||
- Check for code duplication
|
||||
- Evaluate error handling patterns (consistent with existing codebase?)
|
||||
- Check naming conventions and code style
|
||||
- Verify TypeScript types (any `any` usage, missing types?)
|
||||
|
||||
#### 4c. Security Review
|
||||
|
||||
- Check for missing authentication/authorization on new endpoints
|
||||
- Check for injection vulnerabilities (URL params, SQL, XSS)
|
||||
- Verify input validation on all user-controlled data
|
||||
- Check for hardcoded secrets or credentials
|
||||
|
||||
#### 4d. Architecture Review
|
||||
|
||||
- Does the change follow existing patterns?
|
||||
- Are there any breaking changes to public APIs?
|
||||
- Is the database schema affected? Migration needed?
|
||||
- Impact on performance (N+1 queries, missing indexes?)
|
||||
|
||||
#### 4e. Test Coverage
|
||||
|
||||
- Does the PR include tests?
|
||||
- Are edge cases covered?
|
||||
- Would existing tests break?
|
||||
|
||||
#### 4f. Cross-Layer (Global) Analysis
|
||||
|
||||
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
|
||||
|
||||
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
|
||||
- Does a new endpoint require a new screen/page in the dashboard?
|
||||
- Should there be a new action button, menu item, or navigation link?
|
||||
- Are there new data fields that should be displayed or editable in the UI?
|
||||
- Does a new feature need a toggle, configuration panel, or status indicator?
|
||||
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
|
||||
- Are the required API endpoints implemented?
|
||||
- Is the data model sufficient for the new UI components?
|
||||
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
|
||||
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
|
||||
|
||||
### 5. Generate Report — Create a markdown report for each PR including:
|
||||
|
||||
- **PR Summary** — What it does, files affected, commit count
|
||||
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
|
||||
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
|
||||
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
|
||||
- **Verdict** — Ready to merge? With mandatory vs optional fixes
|
||||
- **Next Steps** — What will happen if approved
|
||||
|
||||
### 6. Present to User
|
||||
|
||||
- Show the report in the final response and stop. Mark this as a blocking checkpoint awaiting explicit user approval before continuing.
|
||||
- Wait for user decision:
|
||||
- **Approved** → Proceed to step 7
|
||||
- **Approved with changes** → Implement the fixes and corrections before merging
|
||||
- **Rejected** → Close the PR or leave a review comment
|
||||
|
||||
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
|
||||
|
||||
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
|
||||
|
||||
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
|
||||
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
|
||||
- **Pushing changes to PR branches:**
|
||||
|
||||
```bash
|
||||
# Checkout the PR locally
|
||||
gh pr checkout <NUMBER>
|
||||
|
||||
# Apply fixes, commit your changes
|
||||
git commit -m "chore: apply review suggestions and missing layers"
|
||||
|
||||
# Attempt to push directly to the PR branch
|
||||
git push
|
||||
```
|
||||
|
||||
- **Fallback (ONLY for external forks without maintainer edit access):**
|
||||
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
|
||||
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
|
||||
Even then, ensure you preserve the contributor's authorship (`git commit --author="Contributor Name <email>"` if creating new commits).
|
||||
Once you have integrated their work into the release branch, **DO NOT close their PR**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
|
||||
|
||||
- Run the project's test suite locally to verify nothing breaks:
|
||||
// turbo
|
||||
- Run: `npm test` or equivalent test command
|
||||
|
||||
### 8. Merge into Release Branch (NEVER CLOSE!)
|
||||
|
||||
> **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted. Closing a PR in a contributor's face after taking their idea—or closing it just because it had conflicts—is unacceptable.
|
||||
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
|
||||
|
||||
Even if the PR had severe conflicts or required significant architectural adjustments, you MUST:
|
||||
|
||||
1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7) or use cherry-picking into the release branch.
|
||||
2. If you managed to fix their branch, merge it into the release branch using the GitHub CLI:
|
||||
`gh pr merge <NUMBER> --repo <owner>/<repo> --squash --body "Integrated into release/vX.Y.Z"`
|
||||
3. If you had to use cherry-picking because you couldn't push to their branch, DO NOT close the PR. GitHub will sometimes auto-detect the cherry-picked commits and mark it as Merged. If it doesn't, leave it open. The repository owner will handle it. NEVER run `gh pr close`.
|
||||
|
||||
In ALL cases:
|
||||
|
||||
- Post a **thank-you comment** on the PR via the GitHub API before or immediately after merging.
|
||||
- The message should:
|
||||
- Thank the author by name/username for their contribution.
|
||||
- Explain what was adjusted or improved (if we pushed fixes to their branch or cherry-picked).
|
||||
- Note it will be included in the upcoming release.
|
||||
- Be friendly, professional, and encouraging.
|
||||
|
||||
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
|
||||
|
||||
### 9. Sync Local Release Branch
|
||||
|
||||
After merging PRs, sync the local release branch to include the new changes:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git pull origin release/vX.Y.Z
|
||||
```
|
||||
|
||||
### 10. Continue or Finalize
|
||||
|
||||
After processing all approved PRs:
|
||||
|
||||
- If more PRs remain, go back to step 7
|
||||
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
|
||||
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
- Fix any test regressions introduced by merged PRs
|
||||
- Run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user)
|
||||
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`
|
||||
347
.agents/skills/version-bump/SKILL.md
Normal file
347
.agents/skills/version-bump/SKILL.md
Normal file
@@ -0,0 +1,347 @@
|
||||
---
|
||||
name: version-bump-cx
|
||||
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
|
||||
---
|
||||
|
||||
# Version Bump Workflow
|
||||
|
||||
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
|
||||
|
||||
## Codex Execution Notes
|
||||
|
||||
- Treat `// turbo` / `// turbo-all` as instructions to use `multi_tool_use.parallel` for independent reads, checks, and GitHub calls.
|
||||
- Any user-approval phase is a hard stop: present the report/status in the final response and wait before committing, pushing, tagging, publishing, or deploying.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Determine Version
|
||||
|
||||
### 1. Read current version and last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Last tag: $LAST_TAG"
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
```
|
||||
|
||||
### 2. Calculate new version
|
||||
|
||||
Apply the patch bump rule:
|
||||
|
||||
- If the current patch number is `9`, the new version is `3.(minor+1).0`
|
||||
- Otherwise, increment patch: `3.x.y` → `3.x.(y+1)`
|
||||
|
||||
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
|
||||
|
||||
### 3. Bump package.json (if needed)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
# Only if version hasn't been bumped yet
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
Or for threshold (y=10):
|
||||
|
||||
```bash
|
||||
# Manual threshold bump
|
||||
VERSION="3.X.0" # compute manually
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Generate CHANGELOG from Git History
|
||||
|
||||
### 4. Collect commits since last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
|
||||
echo "=== Commits since $LAST_TAG ==="
|
||||
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
|
||||
echo ""
|
||||
echo "=== Merge commits ==="
|
||||
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
|
||||
```
|
||||
|
||||
### 5. Classify commits and generate CHANGELOG section
|
||||
|
||||
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
|
||||
|
||||
| Category | Patterns |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| ✨ New Features | `feat:`, `feat(*):` |
|
||||
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
|
||||
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
|
||||
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
|
||||
| 🧪 Tests | `test:`, `tests:` |
|
||||
| 📝 Documentation | `docs:` |
|
||||
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
|
||||
| 🌍 i18n | translation updates, locale changes |
|
||||
|
||||
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
|
||||
|
||||
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
|
||||
|
||||
### 6. Update CHANGELOG.md
|
||||
|
||||
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [NEW_VERSION] — YYYY-MM-DD
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Feature name:** Description (#PR)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix name:** Description (#PR)
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **Item:** Description
|
||||
|
||||
---
|
||||
|
||||
## [PREVIOUS_VERSION] — YYYY-MM-DD
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
The date must be today's date in `YYYY-MM-DD` format.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Sync Version Across All Files
|
||||
|
||||
### 7. Update workspace package.json files and openapi.yaml
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Update docs/reference/openapi.yaml version
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ docs/reference/openapi.yaml → $VERSION"
|
||||
|
||||
# Update workspace packages (open-sse, electron)
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ All workspace packages synced to $VERSION"
|
||||
```
|
||||
|
||||
### 8. Update llm.txt version references
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
|
||||
|
||||
# Update "Current version:" line
|
||||
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
|
||||
|
||||
# Update "Key Features (vX.Y.Z)" header
|
||||
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
|
||||
|
||||
echo "✓ llm.txt → $VERSION"
|
||||
```
|
||||
|
||||
### 9. Regenerate lock file
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm install
|
||||
echo "✓ Lock file regenerated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Root Documentation
|
||||
|
||||
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
|
||||
|
||||
### 10. Review and update root documentation files
|
||||
|
||||
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
|
||||
|
||||
| File | When to update |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
|
||||
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
|
||||
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
|
||||
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
|
||||
| `llm.txt` | Provider count changes, new features, architecture changes |
|
||||
|
||||
**Update rules:**
|
||||
|
||||
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
|
||||
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
|
||||
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
|
||||
- **llm.txt**: Update provider count, feature list, version references.
|
||||
|
||||
### 11. Review and update docs/ files (excluding i18n/)
|
||||
|
||||
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
|
||||
|
||||
| File | When to update |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
|
||||
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
|
||||
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
|
||||
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
|
||||
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
|
||||
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
|
||||
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
|
||||
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
|
||||
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
|
||||
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
|
||||
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
|
||||
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
|
||||
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
|
||||
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
|
||||
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
|
||||
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
|
||||
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
|
||||
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
|
||||
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
|
||||
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
|
||||
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
|
||||
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
|
||||
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
|
||||
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
|
||||
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
|
||||
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
|
||||
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
|
||||
| `docs/reference/openapi.yaml` | Already updated in step 7 |
|
||||
|
||||
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### 12. Run lint check
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 13. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm test
|
||||
```
|
||||
|
||||
### 14. Verify version sync across all files
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Expected version: $VERSION"
|
||||
echo ""
|
||||
|
||||
echo "--- package.json ---"
|
||||
grep '"version"' package.json | head -1
|
||||
|
||||
echo "--- open-sse/package.json ---"
|
||||
grep '"version"' open-sse/package.json | head -1
|
||||
|
||||
echo "--- electron/package.json ---"
|
||||
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
|
||||
|
||||
echo "--- docs/reference/openapi.yaml ---"
|
||||
grep " version:" docs/reference/openapi.yaml | head -1
|
||||
|
||||
echo "--- llm.txt ---"
|
||||
grep "Current version:" llm.txt
|
||||
|
||||
echo "--- CHANGELOG.md (first versioned entry) ---"
|
||||
grep "^## \[" CHANGELOG.md | head -2
|
||||
```
|
||||
|
||||
### 15. 🛑 STOP — Present Summary to User
|
||||
|
||||
**STOP** and present a summary to the user including:
|
||||
|
||||
- Old version → New version
|
||||
- CHANGELOG entries generated
|
||||
- Files modified
|
||||
- Test results
|
||||
- Any documentation updates made
|
||||
|
||||
**Wait for the user to confirm before committing.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Commit (only after user approval)
|
||||
|
||||
### 16. Stage and commit
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
git add -A
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
|
||||
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — there is no `/update-i18n` workflow shipped in this repo.
|
||||
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
|
||||
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
|
||||
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
|
||||
|
||||
## Version Touchpoints Checklist
|
||||
|
||||
| File | Field/Pattern |
|
||||
| ----------------------------- | ----------------------------------------------------------- |
|
||||
| `package.json` | `"version": "X.Y.Z"` |
|
||||
| `open-sse/package.json` | `"version": "X.Y.Z"` |
|
||||
| `electron/package.json` | `"version": "X.Y.Z"` |
|
||||
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
|
||||
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
|
||||
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |
|
||||
51
.agents/workflows/capture-release-evidences-ag.md
Normal file
51
.agents/workflows/capture-release-evidences-ag.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
|
||||
---
|
||||
|
||||
# Capture Release Evidences Workflow
|
||||
|
||||
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
|
||||
|
||||
> **Tool mapping note (v3.8):** The `browser_subagent` tool referenced below is specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps remain the same regardless of the browser-automation surface in use.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
|
||||
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### 1. Identify Target Features
|
||||
|
||||
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
|
||||
|
||||
- **CLI Tools Settings**
|
||||
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
|
||||
- **New Feature Modals**
|
||||
|
||||
### 2. Run the Browser Subagent
|
||||
|
||||
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
|
||||
**Important Task Guidelines for the Subagent:**
|
||||
|
||||
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
|
||||
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
|
||||
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
|
||||
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
|
||||
|
||||
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
|
||||
|
||||
### 3. Generate Report Artifact
|
||||
|
||||
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `` syntax.
|
||||
|
||||
### Example Invocation
|
||||
|
||||
\```json
|
||||
{
|
||||
"TaskName": "Validating Qoder PAT Configuration UI",
|
||||
"TaskSummary": "Validates the Qoder provider configuration modal",
|
||||
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
|
||||
"RecordingName": "qoder_pat_ui_validation"
|
||||
}
|
||||
\```
|
||||
39
.agents/workflows/deploy-vps-akamai-ag.md
Normal file
39
.agents/workflows/deploy-vps-akamai-ag.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
|
||||
---
|
||||
|
||||
# Deploy to Akamai VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Akamai VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
```
|
||||
49
.agents/workflows/deploy-vps-both-ag.md
Normal file
49
.agents/workflows/deploy-vps-both-ag.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
|
||||
---
|
||||
|
||||
# Deploy to VPS (Both) Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
39
.agents/workflows/deploy-vps-local-ag.md
Normal file
39
.agents/workflows/deploy-vps-local-ag.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
|
||||
---
|
||||
|
||||
# Deploy to Local VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Local VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
362
.agents/workflows/generate-release-ag.md
Normal file
362
.agents/workflows/generate-release-ag.md
Normal file
@@ -0,0 +1,362 @@
|
||||
---
|
||||
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
|
||||
---
|
||||
|
||||
# Generate Release Workflow
|
||||
|
||||
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
|
||||
|
||||
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Two-Phase Flow
|
||||
|
||||
```
|
||||
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
|
||||
↕ 🛑 STOP: Notify user, wait for PR confirmation
|
||||
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
|
||||
```
|
||||
|
||||
**NEVER push directly to main or create tags before the user confirms the PR.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Security Verification (MANDATORY)
|
||||
|
||||
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
|
||||
|
||||
1. **Run Local Dependencies Audit:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
```
|
||||
|
||||
_Fix any `high` or `critical` vulnerabilities identified._
|
||||
|
||||
2. **Check GitHub CodeQL & Dependabot Alerts:**
|
||||
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Merge
|
||||
|
||||
### 1. Create release branch
|
||||
|
||||
```bash
|
||||
git checkout -b release/v3.x.y
|
||||
```
|
||||
|
||||
### 2. Determine and sync version
|
||||
|
||||
Check current version in `package.json`:
|
||||
|
||||
```bash
|
||||
grep '"version"' package.json
|
||||
```
|
||||
|
||||
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
|
||||
>
|
||||
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
|
||||
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
|
||||
> `npm version patch --no-git-tag-version`
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
>
|
||||
> 1. `npm version patch --no-git-tag-version` ← bump first
|
||||
> 2. implement features / fix bugs
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **OR if features are already staged:**
|
||||
>
|
||||
> 1. implement features (do NOT commit yet)
|
||||
> 2. `npm version patch --no-git-tag-version` ← bump before committing
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **NEVER do this (creates version mismatch in git history):**
|
||||
>
|
||||
> - ~~commit features → then bump version → commit package.json separately~~
|
||||
>
|
||||
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 3. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Finalize CHANGELOG.md
|
||||
|
||||
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
|
||||
|
||||
Replace the `[Unreleased]` header with the new version and date.
|
||||
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] — 2026-04-19
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- ...
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- ...
|
||||
|
||||
---
|
||||
|
||||
## [3.6.9] — 2026-04-19
|
||||
```
|
||||
|
||||
### 5. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ openapi.yaml → $VERSION"
|
||||
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
# Re-run install to assert the workspace lockfile is updated
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Update README.md and i18n docs
|
||||
|
||||
Manually perform these documentation updates (there is no `/update-docs` workflow — it was deprecated in v3.8):
|
||||
|
||||
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
|
||||
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
|
||||
- Update the relevant `docs/<AREA>.md` if architecture or counts changed
|
||||
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
|
||||
|
||||
### 7. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests must pass before creating the PR.
|
||||
|
||||
### 8. Stage, commit, and push
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(release): v3.x.y — summary of changes"
|
||||
git push origin release/v3.x.y
|
||||
```
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extract the exact changelog entry for this version from the root CHANGELOG.md
|
||||
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
|
||||
|
||||
# Append test status and next steps
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### Tests" >> /tmp/changelog_body.txt
|
||||
echo "- All tests pass" >> /tmp/changelog_body.txt
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
|
||||
|
||||
gh pr create \
|
||||
--repo diegosouzapw/OmniRoute \
|
||||
--base main \
|
||||
--head release/v$VERSION \
|
||||
--title "Release v$VERSION" \
|
||||
--body-file /tmp/changelog_body.txt
|
||||
```
|
||||
|
||||
### 10. 🛑 STOP — Notify User & Await PR Confirmation
|
||||
|
||||
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
|
||||
|
||||
Inform the user:
|
||||
|
||||
- PR URL
|
||||
- Summary of changes
|
||||
- Test results
|
||||
- List of files changed
|
||||
|
||||
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post-Merge Validation (Local VPS)
|
||||
|
||||
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
|
||||
|
||||
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
|
||||
|
||||
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Build and pack locally
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
|
||||
# Deploy to LOCAL VPS (192.168.0.15)
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
|
||||
```
|
||||
|
||||
### 12. 🛑 STOP — Notify User & Await Final OK
|
||||
|
||||
**This is a mandatory stop point.**
|
||||
Inform the user that the `main` branch is now running on the Local VPS.
|
||||
Wait for the user to manually test and give the **OK**.
|
||||
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Official Launch
|
||||
|
||||
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
|
||||
|
||||
### 13. Create Git Tag and GitHub Release (MANDATORY)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extracts the changelog section for this version
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
|
||||
|
||||
git tag -a "v$VERSION" -m "Release v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
|
||||
```
|
||||
|
||||
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
|
||||
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
|
||||
> After pushing the tag in step 13, **verify the workflow runs**:
|
||||
|
||||
```bash
|
||||
# Verify the Docker workflow triggered
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
|
||||
|
||||
# Wait for the Docker build to complete (usually 5–10 min)
|
||||
gh run watch --repo diegosouzapw/OmniRoute
|
||||
```
|
||||
|
||||
### 15. Publish to NPM (Optional/Automated)
|
||||
|
||||
Normally handled by CI, but if manual publish is required:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 16. Deploy to AKAMAI VPS (Production)
|
||||
|
||||
Now that the release is officially cut, deploy it to the Akamai VPS.
|
||||
|
||||
```bash
|
||||
# Deploy to AKAMAI VPS (69.164.221.35)
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
|
||||
```
|
||||
|
||||
## Phase 4: Release Monitoring & Artifact Validation
|
||||
|
||||
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
|
||||
|
||||
### 18. Monitor CI Pipelines
|
||||
|
||||
Wait for and verify the successful completion of the following automated jobs:
|
||||
|
||||
1. **Docker Hub Publish**
|
||||
2. **Electron Build**
|
||||
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
|
||||
|
||||
```bash
|
||||
# Monitor Docker Hub workflow
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Monitor Electron build
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Verify NPM version
|
||||
npm info omniroute version
|
||||
```
|
||||
|
||||
### 19. Handle Failures (If Any)
|
||||
|
||||
If a workflow fails:
|
||||
|
||||
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
|
||||
- Apply the fix on the `main` branch.
|
||||
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
|
||||
|
||||
### 20. Preserve release branch
|
||||
|
||||
```bash
|
||||
# Branch is kept for historical purposes. Do not delete.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` workflow anymore)
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
50
.agents/workflows/issue-triage-ag.md
Normal file
50
.agents/workflows/issue-triage-ag.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description: How to respond to GitHub issues with insufficient information
|
||||
---
|
||||
|
||||
# Issue Triage Workflow
|
||||
|
||||
Respond to GitHub issues that need more information before they can be investigated.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify issues needing triage
|
||||
|
||||
```bash
|
||||
gh issue list --state open --limit 20
|
||||
```
|
||||
|
||||
### 2. Evaluate each issue
|
||||
|
||||
Check if the issue has:
|
||||
|
||||
- Clear reproduction steps
|
||||
- Environment details (OS, Node.js version, OmniRoute version)
|
||||
- Error logs/screenshots
|
||||
- Expected vs actual behavior
|
||||
|
||||
### 3. Respond with triage template
|
||||
|
||||
For issues missing information:
|
||||
|
||||
```markdown
|
||||
Thank you for reporting this issue! To help us investigate, please provide:
|
||||
|
||||
1. **OmniRoute version**: (`omniroute --version`)
|
||||
2. **Node.js version**: (`node --version`)
|
||||
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
|
||||
4. **Installation method**: (npm, Docker, source)
|
||||
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
|
||||
6. **Error logs**: (paste relevant logs from the console)
|
||||
7. **Expected behavior**: (what should happen)
|
||||
|
||||
This will help us debug and resolve your issue faster. 🙏
|
||||
```
|
||||
|
||||
### 4. Label the issue
|
||||
|
||||
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
|
||||
|
||||
```bash
|
||||
gh issue edit <NUMBER> --add-label "needs-info"
|
||||
```
|
||||
166
.agents/workflows/resolve-issues-ag.md
Normal file
166
.agents/workflows/resolve-issues-ag.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
|
||||
---
|
||||
|
||||
# /resolve-issues — Automated Issue Resolution Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
|
||||
|
||||
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 1–5.
|
||||
|
||||
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
// turbo
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch All Open Issues
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
|
||||
|
||||
**Step 3a — Get Issue numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one issue number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
|
||||
|
||||
- For each issue number from step 3a, run:
|
||||
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
- Sort by oldest first (FIFO).
|
||||
|
||||
### 4. Classify Each Issue
|
||||
|
||||
For each issue, determine its type:
|
||||
|
||||
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
|
||||
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
|
||||
- **Question** — Has `question` label, or is asking "how to" something
|
||||
- **Other** — Anything else
|
||||
|
||||
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
|
||||
|
||||
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
|
||||
|
||||
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
|
||||
|
||||
#### 5a. Understand the Problem
|
||||
|
||||
For each bug issue, perform the full analysis:
|
||||
|
||||
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
|
||||
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
|
||||
- Whether someone already responded with a fix
|
||||
- Whether a community member confirmed the issue is resolved
|
||||
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
|
||||
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
|
||||
|
||||
#### 5b. Check Information Sufficiency
|
||||
|
||||
Verify the issue contains enough to act on:
|
||||
|
||||
- [ ] Clear description of the problem
|
||||
- [ ] Steps to reproduce OR error logs
|
||||
- [ ] Provider/model/version information
|
||||
- [ ] Expected vs actual behavior
|
||||
|
||||
#### 5c. Determine Issue Disposition
|
||||
|
||||
For each bug, classify into one of 5 actions:
|
||||
|
||||
| Disposition | When to Apply | Action |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
|
||||
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
|
||||
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
|
||||
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
|
||||
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
|
||||
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
|
||||
|
||||
#### 5d. For "FIX — Code Change" Issues
|
||||
|
||||
Before coding, perform deep source analysis to formulate a plan:
|
||||
|
||||
1. **Search the codebase** — `grep_search` for error strings, relevant function names, affected files
|
||||
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
|
||||
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
|
||||
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
|
||||
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
|
||||
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
|
||||
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
|
||||
|
||||
#### 5e. For "RESPOND" Issues
|
||||
|
||||
Post a substantive comment that:
|
||||
|
||||
- Acknowledges the specific error they reported
|
||||
- Explains the likely root cause
|
||||
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
|
||||
- Asks for follow-up info if needed
|
||||
|
||||
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
|
||||
|
||||
### 6. Generate Report & Wait for Validation
|
||||
|
||||
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
|
||||
|
||||
| Issue | Title | Status | Proposed Action / Version |
|
||||
| ----- | ----- | ------------- | ----------------------------------------- |
|
||||
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
|
||||
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
|
||||
| #N | Title | 📝 Respond | Guidance comment to be posted |
|
||||
| #N | Title | ❓ Needs Info | Triage comment to be posted |
|
||||
| #N | Title | ⏭️ Skip | Feature request / not a bug |
|
||||
|
||||
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
|
||||
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
|
||||
|
||||
- If the user says **OK** or approves → Proceed to step 7
|
||||
- If the user requests changes → Adjust the proposed solution and present the report again
|
||||
- If the user rejects → Revert any accidental changes and stop
|
||||
|
||||
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
|
||||
|
||||
After the user validates and gives the OK:
|
||||
|
||||
1. **Implement the fixes** — modify the codebase according to the approved plan.
|
||||
2. **Run tests** — `npm run test:all` (or the specific test file) to ensure 100% pass.
|
||||
3. **Update CHANGELOG.md** with all new bug fix entries.
|
||||
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
|
||||
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
|
||||
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
|
||||
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
|
||||
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
|
||||
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user).
|
||||
|
||||
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.
|
||||
120
.agents/workflows/review-discussions-ag.md
Normal file
120
.agents/workflows/review-discussions-ag.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
|
||||
---
|
||||
|
||||
# /review-discussions — GitHub Discussions Review & Response Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
|
||||
|
||||
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent`, in modern Claude Code substitute with the `gh` CLI via Bash — `gh api graphql` for reading discussions and mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
|
||||
|
||||
// turbo-all
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Fetch All Open Discussions
|
||||
|
||||
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
|
||||
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
|
||||
- For each discussion, fetch the individual page to read the full content and all comments/replies
|
||||
|
||||
### 3. Summarize All Discussions
|
||||
|
||||
For each discussion, extract:
|
||||
|
||||
- **Title** and **#Number**
|
||||
- **Author** (GitHub username)
|
||||
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
|
||||
- **Date** created
|
||||
- **Summary** of the original post (1-2 sentences)
|
||||
- **Comments count** and key participants
|
||||
- **Your previous response** (if any)
|
||||
- **Pending action** — whether a response or follow-up is needed
|
||||
|
||||
### 4. Present Summary Report to User
|
||||
|
||||
Present the full summary to the user organized by category, using a table:
|
||||
|
||||
| # | Category | Title | Author | Date | Status |
|
||||
| --- | -------- | ----- | ------ | ------ | ----------------- |
|
||||
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
|
||||
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
|
||||
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
|
||||
|
||||
Highlight:
|
||||
|
||||
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
|
||||
- **✅ Answered** — Maintainer already responded
|
||||
- **🐛 Bug reported** — A bug was mentioned that needs tracking
|
||||
- **💡 Actionable** — Contains a concrete feature request that could become an issue
|
||||
|
||||
### 5. Draft & Post Responses
|
||||
|
||||
For each discussion that needs a response, draft a reply following these guidelines:
|
||||
|
||||
#### Response Style
|
||||
|
||||
- **Friendly and professional** — Start with "Hey @username!"
|
||||
- **Acknowledge the contribution** — Thank the user for their input
|
||||
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
|
||||
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
|
||||
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
|
||||
- **Keep it concise** — 3-5 paragraphs max
|
||||
|
||||
#### Posting via Browser
|
||||
|
||||
- Use `browser_subagent` to navigate to each discussion and post the comment
|
||||
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
|
||||
- Use regular hyphens `-` instead of em-dashes
|
||||
- Use `->` instead of arrow symbols
|
||||
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
|
||||
- Use `**bold**` and `\`code\`` markdown formatting
|
||||
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
|
||||
- Verify the comment was posted by checking the page shows the new comment
|
||||
|
||||
### 6. Create Issues from Actionable Feature Requests
|
||||
|
||||
For discussions that contain concrete, actionable feature requests:
|
||||
|
||||
1. Ask the user which ones should become issues
|
||||
2. For each approved request, create a GitHub issue via `browser_subagent`:
|
||||
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
|
||||
- **Title**: `<Feature Name> - <Short description>`
|
||||
- **Body** should include:
|
||||
- `## Feature Request` header
|
||||
- `**Source:** Discussion #N by @author`
|
||||
- `## Problem` — What limitation the user hit
|
||||
- `## Proposed Solution` — How it could work
|
||||
- `### Implementation Ideas` — Technical approach
|
||||
- `### Current Workarounds` — What users can do today
|
||||
- `## Additional Context` — Links to related issues/discussions
|
||||
- Add `enhancement` label
|
||||
- Click "Submit new issue" / "Create"
|
||||
3. After creation, go back to the original discussion and post a comment linking to the new issue:
|
||||
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
|
||||
|
||||
### 7. Final Report
|
||||
|
||||
Present a final summary to the user:
|
||||
|
||||
| Discussion | Action Taken |
|
||||
| ---------- | ---------------------------------- |
|
||||
| #N — Title | Responded with workarounds |
|
||||
| #N — Title | Responded + created Issue #N |
|
||||
| #N — Title | Already answered, no action needed |
|
||||
| #N — Title | Responded to follow-up comment |
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
|
||||
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
|
||||
- For discussions in non-English languages, respond in the same language as the original post
|
||||
- Always reference specific dashboard paths, config options, or code files when explaining existing features
|
||||
- When a discussion reveals a bug, note it separately from feature requests
|
||||
256
.agents/workflows/review-prs-ag.md
Normal file
256
.agents/workflows/review-prs-ag.md
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
|
||||
---
|
||||
|
||||
# /review-prs — PR Review & Analysis Workflow
|
||||
|
||||
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
|
||||
|
||||
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
|
||||
>
|
||||
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
|
||||
>
|
||||
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
|
||||
>
|
||||
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
|
||||
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
|
||||
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
|
||||
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
|
||||
>
|
||||
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
|
||||
>
|
||||
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
|
||||
>
|
||||
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
|
||||
|
||||
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Read `package.json` to get the repository URL, or use the git remote origin URL
|
||||
// turbo
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
# Bump patch: e.g. 3.3.11 → 3.3.12
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch Open Pull Requests
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
|
||||
|
||||
**Step 3a — Get PR numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one PR number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each PR** (one call per PR):
|
||||
|
||||
- For each PR number from step 3a, run:
|
||||
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
|
||||
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
|
||||
|
||||
- For each PR number, run:
|
||||
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
|
||||
- Then read each diff file with the appropriate file-read tool (`Read` in Claude Code; equivalent in your agent runtime).
|
||||
|
||||
- For each open PR, collect:
|
||||
- PR number, title, author, branch, number of commits, date
|
||||
- PR description/body
|
||||
- Files changed (diff)
|
||||
- Existing review comments (from bots or humans)
|
||||
|
||||
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
|
||||
|
||||
### 3.5 Redirect PR Base Branches to Release Branch
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
|
||||
|
||||
```bash
|
||||
# Get the current release branch name
|
||||
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
|
||||
|
||||
# For each open PR that targets main, change its base to the release branch
|
||||
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
|
||||
echo "Redirecting PR #$PR_NUM → $RELEASE_BRANCH"
|
||||
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
|
||||
done
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
1. PRs merge into the release branch, not directly into `main`
|
||||
2. Merge conflict detection is accurate against the release branch
|
||||
3. The release branch accumulates all changes before the final merge to `main`
|
||||
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
|
||||
|
||||
### 4. Analyze Each PR — For each open PR, perform the following analysis:
|
||||
|
||||
#### 4a. Feature Assessment
|
||||
|
||||
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
|
||||
- **Alignment** — Check if it aligns with the project's architecture and roadmap
|
||||
- **Complexity** — Assess if the scope is reasonable or if it should be split
|
||||
|
||||
#### 4b. Code Quality Review
|
||||
|
||||
- Check for code duplication
|
||||
- Evaluate error handling patterns (consistent with existing codebase?)
|
||||
- Check naming conventions and code style
|
||||
- Verify TypeScript types (any `any` usage, missing types?)
|
||||
|
||||
#### 4c. Security Review
|
||||
|
||||
- Check for missing authentication/authorization on new endpoints
|
||||
- Check for injection vulnerabilities (URL params, SQL, XSS)
|
||||
- Verify input validation on all user-controlled data
|
||||
- Check for hardcoded secrets or credentials
|
||||
|
||||
#### 4d. Architecture Review
|
||||
|
||||
- Does the change follow existing patterns?
|
||||
- Are there any breaking changes to public APIs?
|
||||
- Is the database schema affected? Migration needed?
|
||||
- Impact on performance (N+1 queries, missing indexes?)
|
||||
|
||||
#### 4e. Test Coverage
|
||||
|
||||
- Does the PR include tests?
|
||||
- Are edge cases covered?
|
||||
- Would existing tests break?
|
||||
|
||||
#### 4f. Cross-Layer (Global) Analysis
|
||||
|
||||
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
|
||||
|
||||
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
|
||||
- Does a new endpoint require a new screen/page in the dashboard?
|
||||
- Should there be a new action button, menu item, or navigation link?
|
||||
- Are there new data fields that should be displayed or editable in the UI?
|
||||
- Does a new feature need a toggle, configuration panel, or status indicator?
|
||||
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
|
||||
- Are the required API endpoints implemented?
|
||||
- Is the data model sufficient for the new UI components?
|
||||
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
|
||||
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
|
||||
|
||||
### 5. Generate Report — Create a markdown report for each PR including:
|
||||
|
||||
- **PR Summary** — What it does, files affected, commit count
|
||||
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
|
||||
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
|
||||
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
|
||||
- **Verdict** — Ready to merge? With mandatory vs optional fixes
|
||||
- **Next Steps** — What will happen if approved
|
||||
|
||||
### 6. Present to User
|
||||
|
||||
- Show the report in the final response and stop. Mark this as a blocking checkpoint awaiting explicit user approval.
|
||||
- Wait for user decision:
|
||||
- **Approved** → Proceed to step 7
|
||||
- **Approved with changes** → Implement the fixes and corrections before merging
|
||||
- **Rejected** → Close the PR or leave a review comment
|
||||
|
||||
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
|
||||
|
||||
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
|
||||
|
||||
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
|
||||
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
|
||||
- **Pushing changes to PR branches:**
|
||||
|
||||
```bash
|
||||
# Checkout the PR locally
|
||||
gh pr checkout <NUMBER>
|
||||
|
||||
# Apply fixes, commit your changes
|
||||
git commit -m "chore: apply review suggestions and missing layers"
|
||||
|
||||
# Attempt to push directly to the PR branch
|
||||
git push
|
||||
```
|
||||
|
||||
- **Fallback (ONLY for external forks without maintainer edit access):**
|
||||
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
|
||||
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
|
||||
Even then, ensure you preserve the contributor's authorship (`git commit --author="Contributor Name <email>"` if creating new commits).
|
||||
Once you have integrated their work into the release branch, **DO NOT close their PR**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
|
||||
|
||||
- Run the project's test suite locally to verify nothing breaks:
|
||||
// turbo
|
||||
- Run: `npm test` or equivalent test command
|
||||
|
||||
### 8. Merge into Release Branch (NEVER CLOSE!)
|
||||
|
||||
> **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted. Closing a PR in a contributor's face after taking their idea—or closing it just because it had conflicts—is unacceptable.
|
||||
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
|
||||
|
||||
Even if the PR had severe conflicts or required significant architectural adjustments, you MUST:
|
||||
|
||||
1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7) or use cherry-picking into the release branch.
|
||||
2. If you managed to fix their branch, merge it into the release branch using the GitHub CLI:
|
||||
`gh pr merge <NUMBER> --repo <owner>/<repo> --squash --body "Integrated into release/vX.Y.Z"`
|
||||
3. If you had to use cherry-picking because you couldn't push to their branch, DO NOT close the PR. GitHub will sometimes auto-detect the cherry-picked commits and mark it as Merged. If it doesn't, leave it open. The repository owner will handle it. NEVER run `gh pr close`.
|
||||
|
||||
In ALL cases:
|
||||
|
||||
- Post a **thank-you comment** on the PR via the GitHub API before or immediately after merging.
|
||||
- The message should:
|
||||
- Thank the author by name/username for their contribution.
|
||||
- Explain what was adjusted or improved (if we pushed fixes to their branch or cherry-picked).
|
||||
- Note it will be included in the upcoming release.
|
||||
- Be friendly, professional, and encouraging.
|
||||
|
||||
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
|
||||
|
||||
### 9. Sync Local Release Branch
|
||||
|
||||
After merging PRs, sync the local release branch to include the new changes:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git pull origin release/vX.Y.Z
|
||||
```
|
||||
|
||||
### 10. Continue or Finalize
|
||||
|
||||
After processing all approved PRs:
|
||||
|
||||
- If more PRs remain, go back to step 7
|
||||
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
|
||||
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
- Fix any test regressions introduced by merged PRs
|
||||
- Run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user)
|
||||
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`
|
||||
341
.agents/workflows/version-bump-ag.md
Normal file
341
.agents/workflows/version-bump-ag.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
|
||||
---
|
||||
|
||||
# Version Bump Workflow
|
||||
|
||||
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Determine Version
|
||||
|
||||
### 1. Read current version and last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Last tag: $LAST_TAG"
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
```
|
||||
|
||||
### 2. Calculate new version
|
||||
|
||||
Apply the patch bump rule:
|
||||
|
||||
- If the current patch number is `9`, the new version is `3.(minor+1).0`
|
||||
- Otherwise, increment patch: `3.x.y` → `3.x.(y+1)`
|
||||
|
||||
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
|
||||
|
||||
### 3. Bump package.json (if needed)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
# Only if version hasn't been bumped yet
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
Or for threshold (y=10):
|
||||
|
||||
```bash
|
||||
# Manual threshold bump
|
||||
VERSION="3.X.0" # compute manually
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Generate CHANGELOG from Git History
|
||||
|
||||
### 4. Collect commits since last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
|
||||
echo "=== Commits since $LAST_TAG ==="
|
||||
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
|
||||
echo ""
|
||||
echo "=== Merge commits ==="
|
||||
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
|
||||
```
|
||||
|
||||
### 5. Classify commits and generate CHANGELOG section
|
||||
|
||||
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
|
||||
|
||||
| Category | Patterns |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| ✨ New Features | `feat:`, `feat(*):` |
|
||||
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
|
||||
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
|
||||
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
|
||||
| 🧪 Tests | `test:`, `tests:` |
|
||||
| 📝 Documentation | `docs:` |
|
||||
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
|
||||
| 🌍 i18n | translation updates, locale changes |
|
||||
|
||||
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
|
||||
|
||||
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
|
||||
|
||||
### 6. Update CHANGELOG.md
|
||||
|
||||
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [NEW_VERSION] — YYYY-MM-DD
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Feature name:** Description (#PR)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix name:** Description (#PR)
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **Item:** Description
|
||||
|
||||
---
|
||||
|
||||
## [PREVIOUS_VERSION] — YYYY-MM-DD
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
The date must be today's date in `YYYY-MM-DD` format.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Sync Version Across All Files
|
||||
|
||||
### 7. Update workspace package.json files and openapi.yaml
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Update docs/reference/openapi.yaml version
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ docs/reference/openapi.yaml → $VERSION"
|
||||
|
||||
# Update workspace packages (open-sse, electron)
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ All workspace packages synced to $VERSION"
|
||||
```
|
||||
|
||||
### 8. Update llm.txt version references
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
|
||||
|
||||
# Update "Current version:" line
|
||||
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
|
||||
|
||||
# Update "Key Features (vX.Y.Z)" header
|
||||
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
|
||||
|
||||
echo "✓ llm.txt → $VERSION"
|
||||
```
|
||||
|
||||
### 9. Regenerate lock file
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm install
|
||||
echo "✓ Lock file regenerated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Root Documentation
|
||||
|
||||
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
|
||||
|
||||
### 10. Review and update root documentation files
|
||||
|
||||
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
|
||||
|
||||
| File | When to update |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
|
||||
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
|
||||
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
|
||||
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
|
||||
| `llm.txt` | Provider count changes, new features, architecture changes |
|
||||
|
||||
**Update rules:**
|
||||
|
||||
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
|
||||
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
|
||||
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
|
||||
- **llm.txt**: Update provider count, feature list, version references.
|
||||
|
||||
### 11. Review and update docs/ files (excluding i18n/)
|
||||
|
||||
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
|
||||
|
||||
| File | When to update |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
|
||||
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
|
||||
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
|
||||
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
|
||||
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
|
||||
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
|
||||
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
|
||||
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
|
||||
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
|
||||
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
|
||||
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
|
||||
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
|
||||
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
|
||||
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
|
||||
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
|
||||
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
|
||||
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
|
||||
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
|
||||
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
|
||||
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
|
||||
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
|
||||
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
|
||||
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
|
||||
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
|
||||
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
|
||||
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
|
||||
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
|
||||
| `docs/reference/openapi.yaml` | Already updated in step 7 |
|
||||
|
||||
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### 12. Run lint check
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 13. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm test
|
||||
```
|
||||
|
||||
### 14. Verify version sync across all files
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Expected version: $VERSION"
|
||||
echo ""
|
||||
|
||||
echo "--- package.json ---"
|
||||
grep '"version"' package.json | head -1
|
||||
|
||||
echo "--- open-sse/package.json ---"
|
||||
grep '"version"' open-sse/package.json | head -1
|
||||
|
||||
echo "--- electron/package.json ---"
|
||||
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
|
||||
|
||||
echo "--- docs/reference/openapi.yaml ---"
|
||||
grep " version:" docs/reference/openapi.yaml | head -1
|
||||
|
||||
echo "--- llm.txt ---"
|
||||
grep "Current version:" llm.txt
|
||||
|
||||
echo "--- CHANGELOG.md (first versioned entry) ---"
|
||||
grep "^## \[" CHANGELOG.md | head -2
|
||||
```
|
||||
|
||||
### 15. 🛑 STOP — Present Summary to User
|
||||
|
||||
**STOP** and present a summary to the user including:
|
||||
|
||||
- Old version → New version
|
||||
- CHANGELOG entries generated
|
||||
- Files modified
|
||||
- Test results
|
||||
- Any documentation updates made
|
||||
|
||||
**Wait for the user to confirm before committing.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Commit (only after user approval)
|
||||
|
||||
### 16. Stage and commit
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
git add -A
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
|
||||
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — there is no `/update-i18n` workflow shipped in this repo.
|
||||
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
|
||||
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
|
||||
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
|
||||
|
||||
## Version Touchpoints Checklist
|
||||
|
||||
| File | Field/Pattern |
|
||||
| ----------------------------- | ----------------------------------------------------------- |
|
||||
| `package.json` | `"version": "X.Y.Z"` |
|
||||
| `open-sse/package.json` | `"version": "X.Y.Z"` |
|
||||
| `electron/package.json` | `"version": "X.Y.Z"` |
|
||||
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
|
||||
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
|
||||
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |
|
||||
51
.claude/commands/capture-release-evidences-cc.md
Normal file
51
.claude/commands/capture-release-evidences-cc.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
description: Automatically run a browser-automation agent to visually validate all new UI features from the current release and capture evidence WebP recordings of the changes.
|
||||
---
|
||||
|
||||
# Capture Release Evidences Workflow
|
||||
|
||||
Use this workflow to automatically drive a browser-automation agent to explore the newly deployed or locally running application and record evidence of the UI changes introduced in the latest release.
|
||||
|
||||
> **Tool mapping note (v3.8):** This workflow references a `browser_subagent` tool that was specific to an earlier agent runtime. In Claude Code, substitute with the available browser MCP tools (e.g. `mcp__claude-in-chrome__*`) for navigation/screenshots, plus the `Write` tool for saving artifacts. The high-level steps below remain the same regardless of which browser-automation surface is used.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- OmniRoute must be actively running and accessible (e.g. locally at `http://localhost:20128` or on the Local VPS at `http://192.168.0.15:20128`).
|
||||
- The user must provide the target URL to be tested, or default to `http://192.168.0.15:20128`.
|
||||
|
||||
## Workflow Steps
|
||||
|
||||
### 1. Identify Target Features
|
||||
|
||||
Review the `CHANGELOG.md` for the latest version to map out the new UI elements. For example:
|
||||
|
||||
- **CLI Tools Settings**
|
||||
- **New Provider/Model Listings (e.g., Gemini 3.1, Qoder PAT)**
|
||||
- **New Feature Modals**
|
||||
|
||||
### 2. Run the Browser Subagent
|
||||
|
||||
For each identified feature, invoke the `browser_subagent` using the `default_api:browser_subagent` tool.
|
||||
**Important Task Guidelines for the Subagent:**
|
||||
|
||||
- `TaskName`: Give it a clear name like "Validate CLIProxyAPI Tool Tab".
|
||||
- `TaskSummary`: "Navigate to the CLI Tools tab and verify the new Integration settings."
|
||||
- `Task`: Provide unambiguous instructions for the subagent, such as: "Navigate to http://192.168.0.15:20128/dashboard. Click on the 'Settings' or 'CLI Tools' nav link. Scroll down to find the CLIProxyAPI integration card. Hover over it to trigger UI state. Verify the components render correctly and exit."
|
||||
- `RecordingName`: Ensure it describes the feature (e.g. `v3_4_5_cli_proxy_api`). This is required and strictly automatically saved as a WebP artifacts video by the system.
|
||||
|
||||
_(Note: The `browser_subagent` automatically creates a WebP recording named by the `RecordingName` parameter. No additional tools for screenshots are needed.)_
|
||||
|
||||
### 3. Generate Report Artifact
|
||||
|
||||
After the `browser_subagent` finishes its sessions, generate a final Markdown artifact (using `Write` and `IsArtifact=true`) to present the recordings inline to the user using the `` syntax.
|
||||
|
||||
### Example Invocation
|
||||
|
||||
\```json
|
||||
{
|
||||
"TaskName": "Validating Qoder PAT Configuration UI",
|
||||
"TaskSummary": "Validates the Qoder provider configuration modal",
|
||||
"Task": "Go to http://192.168.0.15:20128/dashboard. Click on the 'Providers' tab. Find 'Qoder' in the list. Click 'Add Token' or 'Configure'. Type 'test_token' and submit. Return when done.",
|
||||
"RecordingName": "qoder_pat_ui_validation"
|
||||
}
|
||||
\```
|
||||
39
.claude/commands/deploy-vps-akamai-cc.md
Normal file
39
.claude/commands/deploy-vps-akamai-cc.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Akamai VPS (69.164.221.35)
|
||||
---
|
||||
|
||||
# Deploy to Akamai VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Akamai VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Akamai VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
```
|
||||
49
.claude/commands/deploy-vps-both-cc.md
Normal file
49
.claude/commands/deploy-vps-both-cc.md
Normal file
@@ -0,0 +1,49 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to BOTH the Akamai VPS and the Local VPS
|
||||
---
|
||||
|
||||
# Deploy to VPS (Both) Workflow
|
||||
|
||||
Deploy OmniRoute to the production VPSs using `npm pack + scp` + PM2.
|
||||
|
||||
**Akamai VPS:** `69.164.221.35`
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
**PM2 entry:** `/usr/lib/node_modules/omniroute/app/server.js`
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The npm registry rejects packages > 100MB, so deployment uses **npm pack + scp**.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to both VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/ && scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'AKAMAI HTTP %{http_code}\n' http://69.164.221.35:20128/
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
39
.claude/commands/deploy-vps-local-cc.md
Normal file
39
.claude/commands/deploy-vps-local-cc.md
Normal file
@@ -0,0 +1,39 @@
|
||||
---
|
||||
description: Deploy the latest OmniRoute code to the Local VPS (192.168.0.15)
|
||||
---
|
||||
|
||||
# Deploy to Local VPS Workflow
|
||||
|
||||
Deploy OmniRoute to the Local VPS using `npm pack + scp` + PM2.
|
||||
|
||||
**Local VPS:** `192.168.0.15`
|
||||
**Process manager:** PM2 (`omniroute`)
|
||||
**Port:** `20128`
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Build + pack locally
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
```
|
||||
|
||||
### 2. Copy to Local VPS and install
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
```
|
||||
|
||||
```bash
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
```
|
||||
|
||||
### 3. Verify the deployment
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w 'LOCAL HTTP %{http_code}\n' http://192.168.0.15:20128/
|
||||
```
|
||||
362
.claude/commands/generate-release-cc.md
Normal file
362
.claude/commands/generate-release-cc.md
Normal file
@@ -0,0 +1,362 @@
|
||||
---
|
||||
description: Create a new release, bump version up to the .10 patch threshold, update changelog, and manage Pull Requests
|
||||
---
|
||||
|
||||
# Generate Release Workflow
|
||||
|
||||
Bump version, finalize CHANGELOG, commit, open a **PR to main** and wait for user confirmation before tagging, publishing, and deploying.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.8.10` → `3.9.0`.
|
||||
|
||||
> **🔴 SINGLE BRANCH RULE**: The `release/vX.Y.Z` branch is the **ONLY** development branch for the entire release cycle. ALL work — bug fixes, feature implementations, PR integrations, issue resolutions — MUST be committed directly on this branch. Never create separate `fix/`, `feat/`, or topic branches. When running `/resolve-issues`, `/implement-features`, or `/review-prs`, always work on the current release branch.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Two-Phase Flow
|
||||
|
||||
```
|
||||
Phase 1 (automated): bump → docs → i18n → commit → push → open PR
|
||||
↕ 🛑 STOP: Notify user, wait for PR confirmation
|
||||
Phase 2 (post-merge): tag → publish → GitHub release → Docker → deploy
|
||||
```
|
||||
|
||||
**NEVER push directly to main or create tags before the user confirms the PR.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 0: Security Verification (MANDATORY)
|
||||
|
||||
Before creating the release, you must ensure the codebase and supply chain are secure and free of known vulnerabilities.
|
||||
|
||||
1. **Run Local Dependencies Audit:**
|
||||
|
||||
```bash
|
||||
npm audit
|
||||
```
|
||||
|
||||
_Fix any `high` or `critical` vulnerabilities identified._
|
||||
|
||||
2. **Check GitHub CodeQL & Dependabot Alerts:**
|
||||
Navigate to the repository's **Security** tab on GitHub, or use the project's `vulnerability-scanner` skill to analyze active alerts. Ensure all static analysis findings (e.g., prototype pollution, insecure randomness, ReDoS, shell injections) are addressed and logically committed on a target branch.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Pre-Merge
|
||||
|
||||
### 1. Create release branch
|
||||
|
||||
```bash
|
||||
git checkout -b release/v3.x.y
|
||||
```
|
||||
|
||||
### 2. Determine and sync version
|
||||
|
||||
Check current version in `package.json`:
|
||||
|
||||
```bash
|
||||
grep '"version"' package.json
|
||||
```
|
||||
|
||||
> **🔴 BRANCH-VERSION PARITY RULE**: The logical version in `package.json` MUST exactly match the release branch name. For example, if you are on `release/v3.7.0`, the version in `package.json` MUST be `3.7.0`.
|
||||
>
|
||||
> - If this is the FIRST time generating a release for a new minor/major branch (e.g., bumping from 3.6.9 to 3.7.0), you MUST ensure the version is bumped to match the new branch logic.
|
||||
> - If you are just bumping a patch on the current branch (e.g., 3.6.9 to 3.6.10), use:
|
||||
> `npm version patch --no-git-tag-version`
|
||||
|
||||
> **⚠️ ATOMIC COMMIT RULE — Version bump MUST happen before committing feature files.**
|
||||
>
|
||||
> **CORRECT order:**
|
||||
>
|
||||
> 1. `npm version patch --no-git-tag-version` ← bump first
|
||||
> 2. implement features / fix bugs
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **OR if features are already staged:**
|
||||
>
|
||||
> 1. implement features (do NOT commit yet)
|
||||
> 2. `npm version patch --no-git-tag-version` ← bump before committing
|
||||
> 3. `git add -A && git commit -m "chore(release): v3.x.y — all changes in ONE commit"`
|
||||
>
|
||||
> **NEVER do this (creates version mismatch in git history):**
|
||||
>
|
||||
> - ~~commit features → then bump version → commit package.json separately~~
|
||||
>
|
||||
> This ensures that `git show v3.x.y` always contains both code changes and the version bump together.
|
||||
> The GitHub release tag will point to a commit that includes ALL changes for that version.
|
||||
|
||||
### 3. Regenerate lock file (REQUIRED after version bump)
|
||||
|
||||
**Mandatory** — skipping causes `@swc/helpers` lock mismatch and CI failures:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Finalize CHANGELOG.md
|
||||
|
||||
> **🔴 NO MIXUPS RULE**: Ensure you do NOT mix the backlog of the previous version with the new one. The new version section must ONLY contain the features and fixes for the current release.
|
||||
|
||||
Replace the `[Unreleased]` header with the new version and date.
|
||||
Keep an empty `## [Unreleased]` section above it, separated by a horizontal rule (`---`).
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.7.0] — 2026-04-19
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- ...
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- ...
|
||||
|
||||
---
|
||||
|
||||
## [3.6.9] — 2026-04-19
|
||||
```
|
||||
|
||||
### 5. Update openapi.yaml version ⚠️ MANDATORY
|
||||
|
||||
> **CI will fail** if `docs/reference/openapi.yaml` version ≠ `package.json` version (`check:docs-sync` enforces this).
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ openapi.yaml → $VERSION"
|
||||
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
# Re-run install to assert the workspace lockfile is updated
|
||||
npm install
|
||||
```
|
||||
|
||||
### 6. Update README.md and i18n docs
|
||||
|
||||
Manually perform these documentation updates (there is no `/update-docs` slash command — it was deprecated in v3.8):
|
||||
|
||||
- Update feature table rows and "What's new in vX.Y.Z" section in `README.md`
|
||||
- Sync feature changes to all 40 language `docs/i18n/*/README.md` files (use the same row edits across each translated README)
|
||||
- Update the relevant `docs/<AREA>.md` if architecture or counts changed (e.g. `docs/frameworks/MCP-SERVER.md` when MCP tools change)
|
||||
- Re-run `npm run check:docs-sync` and `npm run check:docs-all` to catch drift
|
||||
|
||||
### 7. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
All tests must pass before creating the PR.
|
||||
|
||||
### 8. Stage, commit, and push
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "chore(release): v3.x.y — summary of changes"
|
||||
git push origin release/v3.x.y
|
||||
```
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
### 9. Open PR to main
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extract the exact changelog entry for this version from the root CHANGELOG.md
|
||||
awk "/^## \\[$VERSION\\]/{flag=1; print; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md > /tmp/changelog_body.txt
|
||||
|
||||
# Append test status and next steps
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### Tests" >> /tmp/changelog_body.txt
|
||||
echo "- All tests pass" >> /tmp/changelog_body.txt
|
||||
echo "" >> /tmp/changelog_body.txt
|
||||
echo "### ⚠️ After merging: run Phase 2 steps to tag, publish, and deploy." >> /tmp/changelog_body.txt
|
||||
|
||||
gh pr create \
|
||||
--repo diegosouzapw/OmniRoute \
|
||||
--base main \
|
||||
--head release/v$VERSION \
|
||||
--title "Release v$VERSION" \
|
||||
--body-file /tmp/changelog_body.txt
|
||||
```
|
||||
|
||||
### 10. 🛑 STOP — Notify User & Await PR Confirmation
|
||||
|
||||
**This is a mandatory stop point.** Present the report in the final response and stop. Do not continue to the next phase until the user explicitly approves.
|
||||
|
||||
Inform the user:
|
||||
|
||||
- PR URL
|
||||
- Summary of changes
|
||||
- Test results
|
||||
- List of files changed
|
||||
|
||||
**DO NOT proceed to Phase 2 until the user confirms the PR looks good and merges it.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Post-Merge Validation (Local VPS)
|
||||
|
||||
> Run these steps only AFTER the user has merged the PR into `main` and all CI jobs have passed.
|
||||
|
||||
### 11. Deploy to Local VPS for Final Validation (MANDATORY)
|
||||
|
||||
Before cutting the official git tag and publishing to the world, deploy the `main` branch to the Local VPS for a final homologation test.
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Build and pack locally
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute && rm -f omniroute-*.tgz && rm -rf .next/cache app/.next/cache && npm run build:cli && rm -rf app/logs app/coverage app/.git app/.app-build-backup* && npm pack --ignore-scripts
|
||||
|
||||
# Deploy to LOCAL VPS (192.168.0.15)
|
||||
scp omniroute-*.tgz root@192.168.0.15:/tmp/
|
||||
ssh root@192.168.0.15 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Local done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "LOCAL: HTTP %{http_code}\n" http://192.168.0.15:20128/
|
||||
```
|
||||
|
||||
### 12. 🛑 STOP — Notify User & Await Final OK
|
||||
|
||||
**This is a mandatory stop point.**
|
||||
Inform the user that the `main` branch is now running on the Local VPS.
|
||||
Wait for the user to manually test and give the **OK**.
|
||||
**DO NOT proceed to Phase 3 until the user confirms the local deploy is stable.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Official Launch
|
||||
|
||||
> Run these steps only AFTER the user gives the final OK from the Phase 2 local validation.
|
||||
|
||||
### 13. Create Git Tag and GitHub Release (MANDATORY)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
git checkout main
|
||||
git pull origin main
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Extracts the changelog section for this version
|
||||
NOTES=$(awk "/^## \\[$VERSION\\]/{flag=1; next} /^---/{if(flag) {flag=0; exit}} flag" CHANGELOG.md | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
if [ -z "$NOTES" ]; then NOTES="OmniRoute v$VERSION Release"; fi
|
||||
|
||||
git tag -a "v$VERSION" -m "Release v$VERSION"
|
||||
git push origin "v$VERSION"
|
||||
gh release create "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES" --target main || gh release edit "v$VERSION" --repo diegosouzapw/OmniRoute --title "v$VERSION" --notes "$NOTES"
|
||||
```
|
||||
|
||||
### 14. 🐳 Trigger Docker Hub build (MANDATORY — keep npm and Docker in sync)
|
||||
|
||||
> **CRITICAL**: Docker Hub and npm MUST always publish the same version.
|
||||
> The Docker image is built automatically via GitHub Actions when a new tag is pushed.
|
||||
> After pushing the tag in step 13, **verify the workflow runs**:
|
||||
|
||||
```bash
|
||||
# Verify the Docker workflow triggered
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 3
|
||||
|
||||
# Wait for the Docker build to complete (usually 5–10 min)
|
||||
gh run watch --repo diegosouzapw/OmniRoute
|
||||
```
|
||||
|
||||
### 15. Publish to NPM (Optional/Automated)
|
||||
|
||||
Normally handled by CI, but if manual publish is required:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
### 16. Deploy to AKAMAI VPS (Production)
|
||||
|
||||
Now that the release is officially cut, deploy it to the Akamai VPS.
|
||||
|
||||
```bash
|
||||
# Deploy to AKAMAI VPS (69.164.221.35)
|
||||
scp omniroute-*.tgz root@69.164.221.35:/tmp/
|
||||
ssh root@69.164.221.35 "npm install -g /tmp/omniroute-*.tgz --ignore-scripts && cd /usr/lib/node_modules/omniroute/app && npm rebuild better-sqlite3 && pm2 delete omniroute 2>/dev/null; pm2 start /root/.omniroute/ecosystem.config.cjs --update-env && pm2 save && echo '✅ Akamai done'"
|
||||
|
||||
# Verify
|
||||
curl -s -o /dev/null -w "AKAMAI: HTTP %{http_code}\n" http://69.164.221.35:20128/
|
||||
```
|
||||
|
||||
## Phase 4: Release Monitoring & Artifact Validation
|
||||
|
||||
> After triggering the official release, actively monitor the CI pipelines until all artifacts are successfully generated. If any pipeline fails, stop and apply the necessary corrections before continuing.
|
||||
|
||||
### 18. Monitor CI Pipelines
|
||||
|
||||
Wait for and verify the successful completion of the following automated jobs:
|
||||
|
||||
1. **Docker Hub Publish**
|
||||
2. **Electron Build**
|
||||
3. **NPM Registry Publish** (Check with `npm info omniroute version`)
|
||||
|
||||
```bash
|
||||
# Monitor Docker Hub workflow
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow docker-publish.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Monitor Electron build
|
||||
gh run list --repo diegosouzapw/OmniRoute --workflow electron-release.yml --limit 1
|
||||
gh run watch <RUN_ID>
|
||||
|
||||
# Verify NPM version
|
||||
npm info omniroute version
|
||||
```
|
||||
|
||||
### 19. Handle Failures (If Any)
|
||||
|
||||
If a workflow fails:
|
||||
|
||||
- Use `gh run view <RUN_ID> --log-failed` to identify the error.
|
||||
- Apply the fix on the `main` branch.
|
||||
- If necessary, re-trigger the workflow using `gh workflow run <workflow_name.yml> --repo diegosouzapw/OmniRoute --ref v3.x.y`
|
||||
|
||||
### 20. Preserve release branch
|
||||
|
||||
```bash
|
||||
# Branch is kept for historical purposes. Do not delete.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Ensure CHANGELOG, README and `docs/*` are current BEFORE this workflow — run `npm run check:docs-all` and `/version-bump` first (there is no `/update-docs` slash command anymore)
|
||||
- The `prepublishOnly` script runs `npm run build:cli` automatically during `npm publish`
|
||||
- After npm publish, verify with `npm info omniroute version`
|
||||
- Lock file sync errors are caused by skipping `npm install` after version bump
|
||||
- Use `gh auth switch -u diegosouzapw` if git push fails with wrong account
|
||||
|
||||
## Known CI Pitfalls
|
||||
|
||||
| CI failure | Cause | Fix |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `[docs-sync] FAIL - OpenAPI version differs from package.json` | Skipped step 5 — `docs/reference/openapi.yaml` version not updated | Run step 5 (`sed -i ...`) and commit |
|
||||
| `[docs-sync] FAIL - CHANGELOG.md first section must be "## [Unreleased]"` | `## [Unreleased]` missing or not at top of CHANGELOG | Add `## [Unreleased]\n\n---\n` before the first versioned `## [x.y.z]` |
|
||||
| Electron Linux `.deb` build fails (`FpmTarget` error) | `fpm` Ruby gem not installed on `ubuntu-latest` runner | Already fixed in `electron-release.yml` (`gem install fpm` step) |
|
||||
| Docker Hub `502 error writing layer blob` | Transient Docker Hub network error during ARM64 push | Re-run the Docker publish workflow; no code change needed |
|
||||
50
.claude/commands/issue-triage-cc.md
Normal file
50
.claude/commands/issue-triage-cc.md
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
description: How to respond to GitHub issues with insufficient information
|
||||
---
|
||||
|
||||
# Issue Triage Workflow
|
||||
|
||||
Respond to GitHub issues that need more information before they can be investigated.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify issues needing triage
|
||||
|
||||
```bash
|
||||
gh issue list --state open --limit 20
|
||||
```
|
||||
|
||||
### 2. Evaluate each issue
|
||||
|
||||
Check if the issue has:
|
||||
|
||||
- Clear reproduction steps
|
||||
- Environment details (OS, Node.js version, OmniRoute version)
|
||||
- Error logs/screenshots
|
||||
- Expected vs actual behavior
|
||||
|
||||
### 3. Respond with triage template
|
||||
|
||||
For issues missing information:
|
||||
|
||||
```markdown
|
||||
Thank you for reporting this issue! To help us investigate, please provide:
|
||||
|
||||
1. **OmniRoute version**: (`omniroute --version`)
|
||||
2. **Node.js version**: (`node --version`)
|
||||
3. **Operating system**: (e.g., Ubuntu 24.04, macOS 15, Windows 11)
|
||||
4. **Installation method**: (npm, Docker, source)
|
||||
5. **Steps to reproduce**: (exact commands/actions that trigger the issue)
|
||||
6. **Error logs**: (paste relevant logs from the console)
|
||||
7. **Expected behavior**: (what should happen)
|
||||
|
||||
This will help us debug and resolve your issue faster. 🙏
|
||||
```
|
||||
|
||||
### 4. Label the issue
|
||||
|
||||
Add appropriate labels: `needs-info`, `bug`, `enhancement`, `question`, etc.
|
||||
|
||||
```bash
|
||||
gh issue edit <NUMBER> --add-label "needs-info"
|
||||
```
|
||||
166
.claude/commands/resolve-issues-cc.md
Normal file
166
.claude/commands/resolve-issues-cc.md
Normal file
@@ -0,0 +1,166 @@
|
||||
---
|
||||
description: Fetch all open GitHub issues, analyze bugs, resolve what's possible, triage the rest, wait for user validation, then commit and release
|
||||
---
|
||||
|
||||
# /resolve-issues — Automated Issue Resolution Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open issues from the project's GitHub repository, classifies them, analyzes bugs, proposes a resolution plan, waits for user validation, and ONLY THEN implements the fixes, commits, and closes the issues on the current release branch (`release/vX.Y.Z`). It does NOT merge or release automatically — the release branch is later merged via PR to main.
|
||||
|
||||
> **BRANCH RULE**: All work MUST happen on the current `release/vX.Y.Z` branch. Never create separate `fix/` branches. If no release branch exists yet, create one first using `/generate-release` Phase 1 steps 1–5.
|
||||
|
||||
> **⛔ PR PROHIBITION**: If a fix is associated with a contributor's PR, you MUST merge their PR — NEVER close it and re-implement the fix yourself. See `/review-prs` workflow for the full policy. The `gh pr close` command is FORBIDDEN unless the repository owner explicitly requests it.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
// turbo
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch All Open Issues
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh issue list` can be truncated by the tool, silently hiding issues. You MUST use the two-step approach below to guarantee **all** issues are fetched.
|
||||
|
||||
**Step 3a — Get Issue numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh issue list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one issue number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each Issue** (one call per issue):
|
||||
|
||||
- For each issue number from step 3a, run:
|
||||
`gh issue view <NUMBER> --repo <owner>/<repo> --json number,title,labels,body,comments,createdAt,author`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
- Sort by oldest first (FIFO).
|
||||
|
||||
### 4. Classify Each Issue
|
||||
|
||||
For each issue, determine its type:
|
||||
|
||||
- **Bug** — Has `bug` label, or body contains error messages, stack traces, "doesn't work", "broken", "crash", "error"
|
||||
- **Feature Request** — Has `enhancement`/`feature` label, or body describes new functionality
|
||||
- **Question** — Has `question` label, or is asking "how to" something
|
||||
- **Other** — Anything else
|
||||
|
||||
Focus ONLY on **Bugs** for resolution. Feature requests and questions should be skipped with a note in the final report.
|
||||
|
||||
### 5. Deep-Read Each Bug Issue (One-by-One Analysis)
|
||||
|
||||
**IMPORTANT**: Read each bug issue thoroughly, one at a time, before moving to the next. This is NOT a batch process — each issue needs focused attention.
|
||||
|
||||
#### 5a. Understand the Problem
|
||||
|
||||
For each bug issue, perform the full analysis:
|
||||
|
||||
1. **Read the entire body** — including Description, Steps to Reproduce, Expected/Actual Behavior, Error Logs, and Screenshots
|
||||
2. **Read ALL comments** — including bot triage comments (Kilo, etc.) and owner/community responses. Pay attention to:
|
||||
- Whether someone already responded with a fix
|
||||
- Whether a community member confirmed the issue is resolved
|
||||
- Whether the issue was marked as duplicate by a bot. **WARNING: DO NOT blindly trust bot duplicate labels (e.g., kilo-duplicate). Bots make mistakes. You MUST read the full conversation and do your own independent analysis to determine if it is truly a duplicate or a distinct bug.**
|
||||
3. **Identify the claimed error** — extract the exact error message, status code, and provider/model involved
|
||||
|
||||
#### 5b. Check Information Sufficiency
|
||||
|
||||
Verify the issue contains enough to act on:
|
||||
|
||||
- [ ] Clear description of the problem
|
||||
- [ ] Steps to reproduce OR error logs
|
||||
- [ ] Provider/model/version information
|
||||
- [ ] Expected vs actual behavior
|
||||
|
||||
#### 5c. Determine Issue Disposition
|
||||
|
||||
For each bug, classify into one of 5 actions:
|
||||
|
||||
| Disposition | When to Apply | Action |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- |
|
||||
| **✅ CLOSE — Already Fixed** | Owner responded with fix + no user follow-up, OR community confirmed fix | Close with comment citing which version fixed it |
|
||||
| **✅ CLOSE — Duplicate** | You have independently verified the issue is a duplicate (do NOT rely solely on bot flags) + user provides no new info | Close referencing the original issue |
|
||||
| **✅ CLOSE — Stale** | We requested logs/info > 7 days ago with no reply | Close thanking the user, invite to reopen if needed |
|
||||
| **📝 RESPOND — Needs Info** | Issue is real but missing critical reproduction details | Comment asking for specifics per `/issue-triage` |
|
||||
| **📝 RESPOND — User Config** | Error is caused by unsupported env (Node version, wrong model path, missing API enablement) | Comment explaining the user-side fix |
|
||||
| **🔧 FIX — Code Change** | Root cause is confirmed in the codebase | Research, propose solution in report, wait for approval |
|
||||
|
||||
#### 5d. For "FIX — Code Change" Issues
|
||||
|
||||
Before coding, perform deep source analysis to formulate a plan:
|
||||
|
||||
1. **Search the codebase** — `grep_search` for error strings, relevant function names, affected files
|
||||
2. **Search the web** — for upstream API changes, SDK updates, or breaking changes that explain the bug
|
||||
3. **Read the full source file** — don't rely on grep snippets; understand the surrounding logic
|
||||
4. **Verify the root cause** — confirm the bug is reproducible based on the code, not just a user misconfiguration
|
||||
5. **Formulate a proposed solution** — detail the exact files and lines you will change and how you will solve it.
|
||||
6. **Create an Implementation Plan file** — write your proposed solution to `_tasks/features-vX.Y.Z/<ISSUE_NUMBER>-<short-description>.plan.md` (e.g. `_tasks/features-v3.7.6/1810-auto-restore-probe-failed-db.plan.md`) where `vX.Y.Z` is the current branch version. The plan should contain an Overview, Pre-Implementation Checklist, and detailed Implementation Steps (Files, Changes).
|
||||
7. **DO NOT modify the codebase yet** — wait for user approval on your report and plan first.
|
||||
|
||||
#### 5e. For "RESPOND" Issues
|
||||
|
||||
Post a substantive comment that:
|
||||
|
||||
- Acknowledges the specific error they reported
|
||||
- Explains the likely root cause
|
||||
- Provides concrete steps to resolve (version upgrade, env var fix, model path correction)
|
||||
- Asks for follow-up info if needed
|
||||
|
||||
**Do NOT post generic template responses.** Every comment should reference the user's specific error messages and environment.
|
||||
|
||||
### 6. Generate Report & Wait for Validation
|
||||
|
||||
Present a summary report to the user detailing your proposed actions. For any bugs that need fixing, explicitly explain your proposed solution (files to change and logic) and point out that it will be implemented on the release branch (`release/vX.Y.Z`) after approval.
|
||||
|
||||
| Issue | Title | Status | Proposed Action / Version |
|
||||
| ----- | ----- | ------------- | ----------------------------------------- |
|
||||
| #N | Title | ✅ Close | Already fixed / duplicate (explain why) |
|
||||
| #N | Title | 🔧 Propose | Explanation of the code fix to be applied |
|
||||
| #N | Title | 📝 Respond | Guidance comment to be posted |
|
||||
| #N | Title | ❓ Needs Info | Triage comment to be posted |
|
||||
| #N | Title | ⏭️ Skip | Feature request / not a bug |
|
||||
|
||||
> **⚠️ IMPORTANT**: Do NOT implement code changes, commit, push, or close issues at this step.
|
||||
> Wait for the user to review the proposed fixes and respond with **OK** before proceeding.
|
||||
|
||||
- If the user says **OK** or approves → Proceed to step 7
|
||||
- If the user requests changes → Adjust the proposed solution and present the report again
|
||||
- If the user rejects → Revert any accidental changes and stop
|
||||
|
||||
### 7. Implement Fixes, Run Tests & Commit (only after user approval)
|
||||
|
||||
After the user validates and gives the OK:
|
||||
|
||||
1. **Implement the fixes** — modify the codebase according to the approved plan.
|
||||
2. **Run tests** — `npm run test:all` (or the specific test file) to ensure 100% pass.
|
||||
3. **Update CHANGELOG.md** with all new bug fix entries.
|
||||
4. **Commit** each fix individually on the release branch with message format: `fix: <description> (#<issue_number>)`.
|
||||
5. **Push** the release branch: `git push origin release/vX.Y.Z`.
|
||||
6. **Close resolved issues immediately**. For each issue that was marked as Fixed, run:
|
||||
`gh issue close <NUMBER> --repo <owner>/<repo> --comment "Thank you for reporting! This issue has been fixed and will be included in the next release (vX.Y.Z)."`
|
||||
7. Likewise, close `Duplicate` issues referencing the original, close `Needs Info` if stale, and post the required comments.
|
||||
8. If the project runs automatic releases or needs a PR, proceed to run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user).
|
||||
|
||||
If NO fixes were committed, skip closing and source control steps and just conclude the workflow.
|
||||
120
.claude/commands/review-discussions-cc.md
Normal file
120
.claude/commands/review-discussions-cc.md
Normal file
@@ -0,0 +1,120 @@
|
||||
---
|
||||
description: Read all open GitHub Discussions, summarize them, respond to pending ones, and create issues from actionable feature requests
|
||||
---
|
||||
|
||||
# /review-discussions — GitHub Discussions Review & Response Workflow
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow reads all open GitHub Discussions, generates a categorized summary, identifies which ones need a response, drafts and posts replies, and optionally creates issues from actionable feature requests. It follows the same flow used for Issues but adapted for the Discussions forum.
|
||||
|
||||
> **Tool mapping note (v3.8):** Where steps below say `browser_subagent` (an earlier-runtime tool), in Claude Code use the `gh` CLI via the `Bash` tool — `gh api graphql` for reading discussions and `gh api graphql -F query=...` mutations for posting comments. `WebFetch` is acceptable for read-only HTML scraping when GraphQL is overkill, but prefer `gh` for any write actions.
|
||||
|
||||
// turbo-all
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
- Parse the owner and repo name from the URL
|
||||
|
||||
### 2. Fetch All Open Discussions
|
||||
|
||||
- Use `WebFetch` to fetch `https://github.com/<owner>/<repo>/discussions`
|
||||
- Parse the discussion list to get all discussion titles, IDs, authors, categories, and dates
|
||||
- For each discussion, fetch the individual page to read the full content and all comments/replies
|
||||
|
||||
### 3. Summarize All Discussions
|
||||
|
||||
For each discussion, extract:
|
||||
|
||||
- **Title** and **#Number**
|
||||
- **Author** (GitHub username)
|
||||
- **Category** (Announcements, General, Ideas, Q&A, Show and tell)
|
||||
- **Date** created
|
||||
- **Summary** of the original post (1-2 sentences)
|
||||
- **Comments count** and key participants
|
||||
- **Your previous response** (if any)
|
||||
- **Pending action** — whether a response or follow-up is needed
|
||||
|
||||
### 4. Present Summary Report to User
|
||||
|
||||
Present the full summary to the user organized by category, using a table:
|
||||
|
||||
| # | Category | Title | Author | Date | Status |
|
||||
| --- | -------- | ----- | ------ | ------ | ----------------- |
|
||||
| #N | Ideas | Title | @user | Mar 23 | ⚠️ Needs response |
|
||||
| #N | Q&A | Title | @user | Mar 9 | ✅ Answered |
|
||||
| #N | General | Title | @user | Mar 19 | ⚠️ Needs response |
|
||||
|
||||
Highlight:
|
||||
|
||||
- **⚠️ Needs response** — No reply from maintainer, or a follow-up comment was left unanswered
|
||||
- **✅ Answered** — Maintainer already responded
|
||||
- **🐛 Bug reported** — A bug was mentioned that needs tracking
|
||||
- **💡 Actionable** — Contains a concrete feature request that could become an issue
|
||||
|
||||
### 5. Draft & Post Responses
|
||||
|
||||
For each discussion that needs a response, draft a reply following these guidelines:
|
||||
|
||||
#### Response Style
|
||||
|
||||
- **Friendly and professional** — Start with "Hey @username!"
|
||||
- **Acknowledge the contribution** — Thank the user for their input
|
||||
- **Be specific** — Reference existing features, settings, or dashboard pages if the feature already exists
|
||||
- **Provide workarounds** — If the request isn't implemented yet, suggest current alternatives
|
||||
- **Commit to action** — If the request is valid, state that you'll open an issue or add it to the roadmap
|
||||
- **Keep it concise** — 3-5 paragraphs max
|
||||
|
||||
#### Posting via Browser
|
||||
|
||||
- Use `browser_subagent` to navigate to each discussion and post the comment
|
||||
- **IMPORTANT**: When typing text in GitHub comment boxes via the browser, use only plain ASCII characters:
|
||||
- Use regular hyphens `-` instead of em-dashes
|
||||
- Use `->` instead of arrow symbols
|
||||
- Do NOT use emoji Unicode characters (the browser keyboard may fail on them)
|
||||
- Use `**bold**` and `\`code\`` markdown formatting
|
||||
- Click the green "Comment" button (or "Reply" for threaded replies) after typing
|
||||
- Verify the comment was posted by checking the page shows the new comment
|
||||
|
||||
### 6. Create Issues from Actionable Feature Requests
|
||||
|
||||
For discussions that contain concrete, actionable feature requests:
|
||||
|
||||
1. Ask the user which ones should become issues
|
||||
2. For each approved request, create a GitHub issue via `browser_subagent`:
|
||||
- Navigate to `https://github.com/<owner>/<repo>/issues/new`
|
||||
- **Title**: `<Feature Name> - <Short description>`
|
||||
- **Body** should include:
|
||||
- `## Feature Request` header
|
||||
- `**Source:** Discussion #N by @author`
|
||||
- `## Problem` — What limitation the user hit
|
||||
- `## Proposed Solution` — How it could work
|
||||
- `### Implementation Ideas` — Technical approach
|
||||
- `### Current Workarounds` — What users can do today
|
||||
- `## Additional Context` — Links to related issues/discussions
|
||||
- Add `enhancement` label
|
||||
- Click "Submit new issue" / "Create"
|
||||
3. After creation, go back to the original discussion and post a comment linking to the new issue:
|
||||
- "I've opened Issue #N to track this feature request. Follow along there for updates!"
|
||||
|
||||
### 7. Final Report
|
||||
|
||||
Present a final summary to the user:
|
||||
|
||||
| Discussion | Action Taken |
|
||||
| ---------- | ---------------------------------- |
|
||||
| #N — Title | Responded with workarounds |
|
||||
| #N — Title | Responded + created Issue #N |
|
||||
| #N — Title | Already answered, no action needed |
|
||||
| #N — Title | Responded to follow-up comment |
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow is **interactive** — always present the summary and wait for user approval before posting responses or creating issues
|
||||
- If the user says "pode responder" (or similar approval), proceed with posting all drafted responses
|
||||
- For discussions in non-English languages, respond in the same language as the original post
|
||||
- Always reference specific dashboard paths, config options, or code files when explaining existing features
|
||||
- When a discussion reveals a bug, note it separately from feature requests
|
||||
256
.claude/commands/review-prs-cc.md
Normal file
256
.claude/commands/review-prs-cc.md
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
description: Analyze open Pull Requests from the project's GitHub repository, generate a critical report, and optionally implement approved changes
|
||||
---
|
||||
|
||||
# /review-prs — PR Review & Analysis Workflow
|
||||
|
||||
## ⛔ ABSOLUTE PROHIBITION — Read Before Anything Else
|
||||
|
||||
> **NEVER close a contributor's PR if you intend to use ANY of their code, ideas, or fixes.**
|
||||
>
|
||||
> **NEVER manually integrate contributor code into a release branch and then close their PR.**
|
||||
>
|
||||
> These actions are **STRICTLY FORBIDDEN** under all circumstances:
|
||||
>
|
||||
> 1. ❌ Closing a PR and cherry-picking/copying its code into a release branch
|
||||
> 2. ❌ Closing a PR "because of conflicts" and re-implementing the same fix yourself
|
||||
> 3. ❌ Closing a PR and committing a "similar" solution inspired by it
|
||||
> 4. ❌ Using `gh pr close` on any PR whose content was or will be used
|
||||
>
|
||||
> **Why**: Closing a PR after taking the contributor's work means they get ZERO credit on GitHub — no "Merged" badge, no contribution graph entry, no public record. This is effectively stealing their contribution. An audit found this happened to **37 PRs** in the past.
|
||||
>
|
||||
> **The ONLY acceptable flow**: Resolve conflicts IN the contributor's branch, push fixes TO their branch, then merge THEIR PR via `gh pr merge`. See Step 7 and Step 8 for the exact procedure.
|
||||
>
|
||||
> **When to close a PR**: ONLY when the user (repository owner) explicitly requests it, OR when the PR is clearly spam/malicious, OR when the author themselves asks to close it. In ALL other cases, leave it open.
|
||||
|
||||
## Overview
|
||||
|
||||
This workflow fetches all open PRs from the project's GitHub repository, performs a critical analysis of each one, generates a detailed report, and waits for user approval before proceeding with implementation. **All improvements are committed on the current release branch** (`release/vX.Y.Z`).
|
||||
|
||||
> **BRANCH RULE**: PRs are ALWAYS merged into the current `release/vX.Y.Z` branch, NEVER directly into `main`. The release branch acts as a staging area — only after all PRs are integrated and tests pass does the release branch get merged into `main` via the `/generate-release` workflow.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1. Identify the GitHub Repository
|
||||
|
||||
- Read `package.json` to get the repository URL, or use the git remote origin URL
|
||||
// turbo
|
||||
- Run: `git -C <project_root> remote get-url origin` to extract the owner/repo
|
||||
|
||||
### 2. Ensure Release Branch Exists
|
||||
|
||||
// turbo
|
||||
|
||||
Before doing any work, ensure you are on the current release branch:
|
||||
|
||||
```bash
|
||||
# Check current branch
|
||||
git branch --show-current
|
||||
|
||||
# If on main, determine next version and create the release branch
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
# Bump patch: e.g. 3.3.11 → 3.3.12
|
||||
NEXT=$(node -p "const [a,b,c]=('$VERSION').split('.').map(Number); c>=9?a+'.'+(b+1)+'.0':a+'.'+b+'.'+(c+1)")
|
||||
git checkout -b release/v$NEXT
|
||||
npm version patch --no-git-tag-version
|
||||
npm install
|
||||
```
|
||||
|
||||
If already on a `release/vX.Y.Z` branch, continue working there.
|
||||
|
||||
### 3. Fetch Open Pull Requests
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: The JSON output of `gh pr list` can be truncated by the tool, silently hiding PRs. You MUST use the two-step approach below to guarantee **all** PRs are fetched.
|
||||
|
||||
**Step 3a — Get PR numbers only** (small output, never truncated):
|
||||
|
||||
- Run: `gh pr list --repo <owner>/<repo> --state open --limit 500 --json number --jq '.[].number'`
|
||||
- This outputs one PR number per line. Count them and confirm total.
|
||||
|
||||
**Step 3b — Fetch full metadata for each PR** (one call per PR):
|
||||
|
||||
- For each PR number from step 3a, run:
|
||||
`gh pr view <NUMBER> --repo <owner>/<repo> --json number,title,author,headRefName,baseRefName,body,createdAt,additions,deletions,files`
|
||||
- You may batch these into parallel calls (up to 4 at a time).
|
||||
|
||||
**Step 3c — Fetch diffs for each PR** (one call per PR, saved to /tmp):
|
||||
|
||||
- For each PR number, run:
|
||||
`gh pr diff <NUMBER> --repo <owner>/<repo> > /tmp/pr<NUMBER>.diff`
|
||||
- Then read each diff file with the `Read` tool.
|
||||
|
||||
- For each open PR, collect:
|
||||
- PR number, title, author, branch, number of commits, date
|
||||
- PR description/body
|
||||
- Files changed (diff)
|
||||
- Existing review comments (from bots or humans)
|
||||
|
||||
**Verification**: Confirm the count of PRs analyzed matches the count from step 3a before proceeding.
|
||||
|
||||
### 3.5 Redirect PR Base Branches to Release Branch
|
||||
|
||||
// turbo-all
|
||||
|
||||
**⚠️ CRITICAL**: Contributors typically open PRs targeting `main`. Before analyzing or merging, redirect ALL open PRs to target the current release branch instead.
|
||||
|
||||
```bash
|
||||
# Get the current release branch name
|
||||
RELEASE_BRANCH=$(git branch --show-current) # e.g. release/v3.5.4
|
||||
|
||||
# For each open PR that targets main, change its base to the release branch
|
||||
for PR_NUM in $(gh pr list --repo <owner>/<repo> --state open --json number,baseRefName --jq '.[] | select(.baseRefName == "main") | .number'); do
|
||||
echo "Redirecting PR #$PR_NUM → $RELEASE_BRANCH"
|
||||
gh pr edit "$PR_NUM" --repo <owner>/<repo> --base "$RELEASE_BRANCH"
|
||||
done
|
||||
```
|
||||
|
||||
This ensures:
|
||||
|
||||
1. PRs merge into the release branch, not directly into `main`
|
||||
2. Merge conflict detection is accurate against the release branch
|
||||
3. The release branch accumulates all changes before the final merge to `main`
|
||||
4. If the release branch doesn't exist on remote yet, push it first: `git push origin $RELEASE_BRANCH`
|
||||
|
||||
### 4. Analyze Each PR — For each open PR, perform the following analysis:
|
||||
|
||||
#### 4a. Feature Assessment
|
||||
|
||||
- **Does it make sense?** Evaluate if the feature fills a real gap or solves a valid problem
|
||||
- **Alignment** — Check if it aligns with the project's architecture and roadmap
|
||||
- **Complexity** — Assess if the scope is reasonable or if it should be split
|
||||
|
||||
#### 4b. Code Quality Review
|
||||
|
||||
- Check for code duplication
|
||||
- Evaluate error handling patterns (consistent with existing codebase?)
|
||||
- Check naming conventions and code style
|
||||
- Verify TypeScript types (any `any` usage, missing types?)
|
||||
|
||||
#### 4c. Security Review
|
||||
|
||||
- Check for missing authentication/authorization on new endpoints
|
||||
- Check for injection vulnerabilities (URL params, SQL, XSS)
|
||||
- Verify input validation on all user-controlled data
|
||||
- Check for hardcoded secrets or credentials
|
||||
|
||||
#### 4d. Architecture Review
|
||||
|
||||
- Does the change follow existing patterns?
|
||||
- Are there any breaking changes to public APIs?
|
||||
- Is the database schema affected? Migration needed?
|
||||
- Impact on performance (N+1 queries, missing indexes?)
|
||||
|
||||
#### 4e. Test Coverage
|
||||
|
||||
- Does the PR include tests?
|
||||
- Are edge cases covered?
|
||||
- Would existing tests break?
|
||||
|
||||
#### 4f. Cross-Layer (Global) Analysis
|
||||
|
||||
Perform a **global impact assessment** to verify whether the PR changes are complete across all layers of the application:
|
||||
|
||||
- **Backend → Frontend check**: If the PR adds or modifies backend-only resources (new endpoints, services, data models), evaluate whether corresponding frontend changes are missing:
|
||||
- Does a new endpoint require a new screen/page in the dashboard?
|
||||
- Should there be a new action button, menu item, or navigation link?
|
||||
- Are there new data fields that should be displayed or editable in the UI?
|
||||
- Does a new feature need a toggle, configuration panel, or status indicator?
|
||||
- **Frontend → Backend check**: If the PR adds frontend elements, verify the backend support exists:
|
||||
- Are the required API endpoints implemented?
|
||||
- Is the data model sufficient for the new UI components?
|
||||
- **Cross-cutting concerns**: Check shared layers (types, DTOs, validation schemas, routes, middleware) for completeness
|
||||
- **Document gaps** — If missing layers are detected, list them as **IMPORTANT** issues in the report with concrete suggestions for what should be added
|
||||
|
||||
### 5. Generate Report — Create a markdown report for each PR including:
|
||||
|
||||
- **PR Summary** — What it does, files affected, commit count
|
||||
- **Improvements/Benefits** — Numbered list with impact level (HIGH/MEDIUM/LOW)
|
||||
- **Risks & Issues** — Categorized as CRITICAL / IMPORTANT / MINOR
|
||||
- **Scoring Table** — Rate across: Feature Relevance, Code Quality, Security, Robustness, Tests
|
||||
- **Verdict** — Ready to merge? With mandatory vs optional fixes
|
||||
- **Next Steps** — What will happen if approved
|
||||
|
||||
### 6. Present to User
|
||||
|
||||
- Show the report in the final response and stop. This is a mandatory checkpoint awaiting explicit user approval before continuing.
|
||||
- Wait for user decision:
|
||||
- **Approved** → Proceed to step 7
|
||||
- **Approved with changes** → Implement the fixes and corrections before merging
|
||||
- **Rejected** → Close the PR or leave a review comment
|
||||
|
||||
### 7. Pre-Merge Fixes & CI Green-Lighting (if approved)
|
||||
|
||||
> **⚠️ Fixes and Conflict Resolutions MUST be pushed back to the PR branch before merging.** We want the PR itself to be green and fully valid before it integrates.
|
||||
|
||||
- **Sync latest fixes & Resolve Conflicts:** Merge the current `release` branch into the PR branch. If there are merge conflicts, you MUST resolve them inside the author's PR branch. NEVER resolve conflicts by closing their PR and doing the work in a separate branch, as this steals credit from the original author.
|
||||
- **Implement improvements:** Apply the required fixes identified in the analysis directly on the PR branch (e.g., adding missing API routes, fixing SSRF, applying comments from other agents).
|
||||
- **Pushing changes to PR branches:**
|
||||
|
||||
```bash
|
||||
# Checkout the PR locally
|
||||
gh pr checkout <NUMBER>
|
||||
|
||||
# Apply fixes, commit your changes
|
||||
git commit -m "chore: apply review suggestions and missing layers"
|
||||
|
||||
# Attempt to push directly to the PR branch
|
||||
git push
|
||||
```
|
||||
|
||||
- **Fallback (ONLY for external forks without maintainer edit access):**
|
||||
Using `cherry-pick` instead of fixing the contributor's PR directly is a **LAST RESORT**. You MUST ALWAYS attempt to `git push` your fixes to their branch first.
|
||||
**ONLY if `git push` explicitly fails with a permission/access error** (meaning the contributor unchecked "Allow edits from maintainers" or it's a locked fork), you may use `git cherry-pick` to bring their changes into the release branch and fix the issues locally.
|
||||
Even then, ensure you preserve the contributor's authorship (`git commit --author="Contributor Name <email>"` if creating new commits).
|
||||
Once you have integrated their work into the release branch, **DO NOT close their PR**. Leave it open so the contributor retains credit. Under NO CIRCUMSTANCES should you use `gh pr close`.
|
||||
|
||||
- Run the project's test suite locally to verify nothing breaks:
|
||||
// turbo
|
||||
- Run: `npm test` or equivalent test command
|
||||
|
||||
### 8. Merge into Release Branch (NEVER CLOSE!)
|
||||
|
||||
> **⚠️ CRITICAL**: NEVER use `gh pr close` for a PR whose idea or code was accepted. Closing a PR in a contributor's face after taking their idea—or closing it just because it had conflicts—is unacceptable.
|
||||
> You MUST ALWAYS resolve conflicts and apply fixes ON THE AUTHOR'S PR BRANCH (unless explicitly locked from edits), and then merge the PR using GitHub so the contributor gets the official "Merged" badge and proper credit on their profile. **Do not use cherry-pick just because it is "easier" than resolving conflicts on their branch.**
|
||||
|
||||
Even if the PR had severe conflicts or required significant architectural adjustments, you MUST:
|
||||
|
||||
1. Resolve any conflicts and apply the fixes directly to their PR branch (as detailed in step 7) or use cherry-picking into the release branch.
|
||||
2. If you managed to fix their branch, merge it into the release branch using the GitHub CLI:
|
||||
`gh pr merge <NUMBER> --repo <owner>/<repo> --squash --body "Integrated into release/vX.Y.Z"`
|
||||
3. If you had to use cherry-picking because you couldn't push to their branch, DO NOT close the PR. GitHub will sometimes auto-detect the cherry-picked commits and mark it as Merged. If it doesn't, leave it open. The repository owner will handle it. NEVER run `gh pr close`.
|
||||
|
||||
In ALL cases:
|
||||
|
||||
- Post a **thank-you comment** on the PR via the GitHub API before or immediately after merging.
|
||||
- The message should:
|
||||
- Thank the author by name/username for their contribution.
|
||||
- Explain what was adjusted or improved (if we pushed fixes to their branch or cherry-picked).
|
||||
- Note it will be included in the upcoming release.
|
||||
- Be friendly, professional, and encouraging.
|
||||
|
||||
> **⚠️ MANDATORY CHANGELOG CREDIT**: When cherry-picking is used (because the PR branch couldn't be pushed to or `gh pr merge` failed), the contributor does NOT get the automatic GitHub "Merged" badge. In this case, you MUST compensate by adding an explicit entry to `CHANGELOG.md` in the `[Unreleased]` section with `(#PR_NUMBER — thanks @username)` format. This ensures the contributor gets public credit in the release notes even if GitHub doesn't auto-detect the cherry-pick. This is NOT optional — skipping it effectively erases the contributor's work from the release record.
|
||||
|
||||
### 9. Sync Local Release Branch
|
||||
|
||||
After merging PRs, sync the local release branch to include the new changes:
|
||||
|
||||
```bash
|
||||
git fetch origin
|
||||
git pull origin release/vX.Y.Z
|
||||
```
|
||||
|
||||
### 10. Continue or Finalize
|
||||
|
||||
After processing all approved PRs:
|
||||
|
||||
- If more PRs remain, go back to step 7
|
||||
- When all PRs are processed, **update CHANGELOG.md** on the release branch with all new entries
|
||||
- Run **test coverage** to verify the gate (≥75% statements/lines/functions, ≥70% branches — measured ~82%):
|
||||
```bash
|
||||
npm run test:coverage
|
||||
```
|
||||
- Fix any test regressions introduced by merged PRs
|
||||
- Run `/generate-release` workflow Phase 1 steps 7–10 (tests → commit → push → open PR to main → wait for user)
|
||||
- The `/generate-release` workflow handles the final merge from `release/vX.Y.Z` → `main`
|
||||
341
.claude/commands/version-bump-cc.md
Normal file
341
.claude/commands/version-bump-cc.md
Normal file
@@ -0,0 +1,341 @@
|
||||
---
|
||||
description: Bump version, auto-generate CHANGELOG from git commits, update all versioned files, and refresh root + docs/ documentation to reflect the current project state
|
||||
---
|
||||
|
||||
# Version Bump Workflow
|
||||
|
||||
Automatically bump the project version, generate CHANGELOG entries from git history since the last tag, update every file that references the version, and refresh project documentation to reflect the current state.
|
||||
|
||||
> **VERSION RULE: Always use PATCH bumps (3.x.y → 3.x.y+1)**
|
||||
> NEVER use `npm version minor` or `npm version major`.
|
||||
> Always use: `npm version patch --no-git-tag-version`
|
||||
> The threshold rule: when `y` reaches 10, bump to `3.(x+1).0` — e.g. `3.4.10` → `3.5.0`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Determine Version
|
||||
|
||||
### 1. Read current version and last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
CURRENT_BRANCH=$(git branch --show-current)
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
echo "Last tag: $LAST_TAG"
|
||||
echo "Current branch: $CURRENT_BRANCH"
|
||||
```
|
||||
|
||||
### 2. Calculate new version
|
||||
|
||||
Apply the patch bump rule:
|
||||
|
||||
- If the current patch number is `9`, the new version is `3.(minor+1).0`
|
||||
- Otherwise, increment patch: `3.x.y` → `3.x.(y+1)`
|
||||
|
||||
If the version was ALREADY bumped (e.g. you are on a release branch and package.json already has the new version), **skip the npm version bump** and use the existing version.
|
||||
|
||||
### 3. Bump package.json (if needed)
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
# Only if version hasn't been bumped yet
|
||||
npm version patch --no-git-tag-version
|
||||
```
|
||||
|
||||
Or for threshold (y=10):
|
||||
|
||||
```bash
|
||||
# Manual threshold bump
|
||||
VERSION="3.X.0" # compute manually
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Generate CHANGELOG from Git History
|
||||
|
||||
### 4. Collect commits since last tag
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null)
|
||||
echo "=== Commits since $LAST_TAG ==="
|
||||
git log "$LAST_TAG"..HEAD --pretty=format:"%h %s" --no-merges | head -100
|
||||
echo ""
|
||||
echo "=== Merge commits ==="
|
||||
git log "$LAST_TAG"..HEAD --merges --pretty=format:"%h %s" | head -50
|
||||
```
|
||||
|
||||
### 5. Classify commits and generate CHANGELOG section
|
||||
|
||||
Analyze each commit message and classify into categories based on the conventional-commit prefix and content:
|
||||
|
||||
| Category | Patterns |
|
||||
| ------------------- | ------------------------------------------------ |
|
||||
| ✨ New Features | `feat:`, `feat(*):` |
|
||||
| 🐛 Bug Fixes | `fix:`, `fix(*):` |
|
||||
| ⚠️ Breaking Changes | `BREAKING CHANGE`, `!:` suffix |
|
||||
| 🛠️ Maintenance | `chore:`, `refactor:`, `perf:`, `build:` |
|
||||
| 🧪 Tests | `test:`, `tests:` |
|
||||
| 📝 Documentation | `docs:` |
|
||||
| 🔒 Security | `security:`, CVE references, vulnerability fixes |
|
||||
| 🌍 i18n | translation updates, locale changes |
|
||||
|
||||
For each category with entries, create a markdown section with descriptive bullet points. Use the commit messages but rewrite them to be human-readable and descriptive (not raw commit messages).
|
||||
|
||||
**If a commit references a PR number** (e.g. `#880`, `PR #885`), include it in the description.
|
||||
|
||||
### 6. Update CHANGELOG.md
|
||||
|
||||
Replace the `## [Unreleased]` section content with the generated entries, then add the new versioned section:
|
||||
|
||||
```markdown
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [NEW_VERSION] — YYYY-MM-DD
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- **Feature name:** Description (#PR)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- **Fix name:** Description (#PR)
|
||||
|
||||
### 🛠️ Maintenance
|
||||
|
||||
- **Item:** Description
|
||||
|
||||
---
|
||||
|
||||
## [PREVIOUS_VERSION] — YYYY-MM-DD
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
The date must be today's date in `YYYY-MM-DD` format.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Sync Version Across All Files
|
||||
|
||||
### 7. Update workspace package.json files and openapi.yaml
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
# Update docs/reference/openapi.yaml version
|
||||
sed -i "s/ version: .*/ version: $VERSION/" docs/reference/openapi.yaml
|
||||
echo "✓ docs/reference/openapi.yaml → $VERSION"
|
||||
|
||||
# Update workspace packages (open-sse, electron)
|
||||
for dir in electron open-sse; do
|
||||
if [ -d "$dir" ] && [ -f "$dir/package.json" ]; then
|
||||
(cd "$dir" && npm version "$VERSION" --no-git-tag-version --allow-same-version > /dev/null)
|
||||
echo "✓ $dir/package.json → $VERSION"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ All workspace packages synced to $VERSION"
|
||||
```
|
||||
|
||||
### 8. Update llm.txt version references
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
OLD_VERSION_PATTERN='[0-9]\+\.[0-9]\+\.[0-9]\+'
|
||||
|
||||
# Update "Current version:" line
|
||||
sed -i "s/\*\*Current version:\*\* $OLD_VERSION_PATTERN/**Current version:** $VERSION/" llm.txt
|
||||
|
||||
# Update "Key Features (vX.Y.Z)" header
|
||||
sed -i "s/## Key Features (v$OLD_VERSION_PATTERN)/## Key Features (v$VERSION)/" llm.txt
|
||||
|
||||
echo "✓ llm.txt → $VERSION"
|
||||
```
|
||||
|
||||
### 9. Regenerate lock file
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm install
|
||||
echo "✓ Lock file regenerated"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Update Root Documentation
|
||||
|
||||
Based on the CHANGELOG entries generated in Phase 2, review and update these root-level files if relevant changes warrant updates:
|
||||
|
||||
### 10. Review and update root documentation files
|
||||
|
||||
For each file below, read the current content and determine if the CHANGELOG entries require any updates. Only modify files where substantive changes have occurred:
|
||||
|
||||
| File | When to update |
|
||||
| ----------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `README.md` | New providers, major features, stats changes (test count, provider count), badges, installation instructions, feature table |
|
||||
| `AGENTS.md` | Architecture changes, new modules, new commands, new providers, new services/handlers/executors |
|
||||
| `CONTRIBUTING.md` | Dev workflow changes, new tooling, test infrastructure changes |
|
||||
| `SECURITY.md` | Security fixes, new auth mechanisms, vulnerability disclosures |
|
||||
| `llm.txt` | Provider count changes, new features, architecture changes |
|
||||
|
||||
**Update rules:**
|
||||
|
||||
- **README.md**: Update provider count, test count, feature highlights table, badges if any numbers changed. If a new provider was added, add it to the provider table. If a major feature was added, add it to the features section.
|
||||
- **AGENTS.md**: If new architecture components (handlers, executors, services, DB modules) were added, update the Architecture section. If new commands were added, update the Build/Test table.
|
||||
- **SECURITY.md**: Add new vulnerability fixes or security improvements to the relevant section.
|
||||
- **llm.txt**: Update provider count, feature list, version references.
|
||||
|
||||
### 11. Review and update docs/ files (excluding i18n/)
|
||||
|
||||
For each file in `docs/` (excluding `docs/i18n/`), review if CHANGELOG changes affect it:
|
||||
|
||||
| File | When to update |
|
||||
| --------------------------------------------- | ------------------------------------------------------------------ |
|
||||
| `docs/reference/API_REFERENCE.md` | New API endpoints, changed request/response formats |
|
||||
| `docs/architecture/ARCHITECTURE.md` | New modules, new services, changed data flow |
|
||||
| `docs/architecture/CODEBASE_DOCUMENTATION.md` | New files, architectural changes, module reorganization |
|
||||
| `docs/architecture/REPOSITORY_MAP.md` | New folders / files / one-line descriptions |
|
||||
| `docs/reference/CLI-TOOLS.md` | New CLI tool integrations, config format changes |
|
||||
| `docs/guides/USER_GUIDE.md` | UX changes, new dashboard pages, settings changes |
|
||||
| `docs/reference/PROVIDER_REFERENCE.md` | New providers (regenerate via `scripts/gen-provider-reference.ts`) |
|
||||
| `docs/frameworks/MCP-SERVER.md` | New MCP tools, changed tool signatures, scope changes |
|
||||
| `docs/frameworks/A2A-SERVER.md` | New A2A skills, protocol changes |
|
||||
| `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` | New external agent protocols supported |
|
||||
| `docs/frameworks/CLOUD_AGENT.md` | Cloud agent additions (codex-cloud, devin, jules) or API changes |
|
||||
| `docs/architecture/AUTHZ_GUIDE.md` | New route classifications, policy changes |
|
||||
| `docs/security/GUARDRAILS.md` | New guardrails registered, priority/order changes |
|
||||
| `docs/security/COMPLIANCE.md` | Audit log / retention / no-log policy changes |
|
||||
| `docs/frameworks/SKILLS.md` | Skill framework / registry / built-in skill changes |
|
||||
| `docs/frameworks/MEMORY.md` | Memory pipeline / extraction / injection / Qdrant changes |
|
||||
| `docs/frameworks/EVALS.md` | Evaluation framework changes, new evaluators |
|
||||
| `docs/frameworks/WEBHOOKS.md` | New webhook events, payload schema changes |
|
||||
| `docs/routing/REASONING_REPLAY.md` | Reasoning capture/replay pipeline changes |
|
||||
| `docs/routing/AUTO-COMBO.md` | Routing changes, new strategies, scoring weight changes |
|
||||
| `docs/architecture/RESILIENCE_GUIDE.md` | Circuit breaker / cooldown / lockout behavior changes |
|
||||
| `docs/security/STEALTH_GUIDE.md` | TLS / CLI fingerprint changes |
|
||||
| `docs/ops/TUNNELS_GUIDE.md` | Cloudflare tunnel feature changes |
|
||||
| `docs/guides/ELECTRON_GUIDE.md` | Electron build / signing / packaging changes |
|
||||
| `docs/guides/TROUBLESHOOTING.md` | New known issues, resolved problems |
|
||||
| `docs/ops/RELEASE_CHECKLIST.md` | Process changes |
|
||||
| `docs/ops/COVERAGE_PLAN.md` | Coverage gate adjustments, target metrics |
|
||||
| `docs/reference/openapi.yaml` | Already updated in step 7 |
|
||||
|
||||
**Only update files where the CHANGELOG entries directly affect the documented content.** Do NOT update files just to bump a version number — only when the documented behavior, features, or architecture has actually changed.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Verify
|
||||
|
||||
### 12. Run lint check
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm run lint
|
||||
```
|
||||
|
||||
### 13. Run tests
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
npm test
|
||||
```
|
||||
|
||||
### 14. Verify version sync across all files
|
||||
|
||||
// turbo
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "Expected version: $VERSION"
|
||||
echo ""
|
||||
|
||||
echo "--- package.json ---"
|
||||
grep '"version"' package.json | head -1
|
||||
|
||||
echo "--- open-sse/package.json ---"
|
||||
grep '"version"' open-sse/package.json | head -1
|
||||
|
||||
echo "--- electron/package.json ---"
|
||||
[ -f electron/package.json ] && grep '"version"' electron/package.json | head -1
|
||||
|
||||
echo "--- docs/reference/openapi.yaml ---"
|
||||
grep " version:" docs/reference/openapi.yaml | head -1
|
||||
|
||||
echo "--- llm.txt ---"
|
||||
grep "Current version:" llm.txt
|
||||
|
||||
echo "--- CHANGELOG.md (first versioned entry) ---"
|
||||
grep "^## \[" CHANGELOG.md | head -2
|
||||
```
|
||||
|
||||
### 15. 🛑 STOP — Present Summary to User
|
||||
|
||||
**STOP** and present a summary to the user including:
|
||||
|
||||
- Old version → New version
|
||||
- CHANGELOG entries generated
|
||||
- Files modified
|
||||
- Test results
|
||||
- Any documentation updates made
|
||||
|
||||
**Wait for the user to confirm before committing.**
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Commit (only after user approval)
|
||||
|
||||
### 16. Stage and commit
|
||||
|
||||
// turbo-all
|
||||
|
||||
```bash
|
||||
cd /home/diegosouzapw/dev/proxys/OmniRoute
|
||||
git add -A
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
git commit -m "chore(release): bump to v$VERSION — changelog, docs, version sync"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- This workflow does **NOT** create tags, releases, or deploy. Use `/generate-release` for the full release cycle after this.
|
||||
- This workflow does **NOT** update `docs/i18n/` translations. Translation updates are handled manually or via release tooling — the `/update-i18n` command does not currently exist as a Claude Code slash command.
|
||||
- The CHANGELOG generation is based on git commits since the last tag. If there are no new commits, the workflow should inform the user and stop.
|
||||
- Always verify the generated CHANGELOG entries make sense — raw commit messages may need rewriting for clarity.
|
||||
- If the version was already bumped (e.g. you're on a `release/vX.Y.Z` branch), skip the `npm version` step and use the existing version.
|
||||
|
||||
## Version Touchpoints Checklist
|
||||
|
||||
| File | Field/Pattern |
|
||||
| ----------------------------- | ----------------------------------------------------------- |
|
||||
| `package.json` | `"version": "X.Y.Z"` |
|
||||
| `open-sse/package.json` | `"version": "X.Y.Z"` |
|
||||
| `electron/package.json` | `"version": "X.Y.Z"` |
|
||||
| `docs/reference/openapi.yaml` | `version: X.Y.Z` |
|
||||
| `llm.txt` | `**Current version:** X.Y.Z` and `## Key Features (vX.Y.Z)` |
|
||||
| `CHANGELOG.md` | `## [X.Y.Z] — YYYY-MM-DD` |
|
||||
@@ -9,7 +9,6 @@
|
||||
# Dependencies and build output
|
||||
node_modules
|
||||
.next
|
||||
.build
|
||||
out
|
||||
build
|
||||
dist
|
||||
@@ -42,18 +41,20 @@ blob-report
|
||||
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
|
||||
# runtime. The previous `docs/*` block hid every file except openapi.yaml,
|
||||
# so the in-product help screen failed with ENOENT for every page.
|
||||
# We now keep the English markdown tree plus the docs assets imported by MDX
|
||||
# during `next build`, while still dropping the bulky translated docs and
|
||||
# extra raster diagram sources that account for most of the docs footprint
|
||||
# We now keep the English markdown tree but drop the bulky assets
|
||||
# (translations, screenshots, raster diagrams) that account for ~45 MB
|
||||
# of the ~50 MB docs directory. The Docs viewer reads the default-locale
|
||||
# (English) sources at runtime, so translations are not required in the
|
||||
# container image.
|
||||
docs/i18n/**
|
||||
docs/screenshots/**
|
||||
docs/diagrams/exported/**
|
||||
docs/diagrams/**/*.png
|
||||
docs/diagrams/**/*.jpg
|
||||
docs/diagrams/**/*.jpeg
|
||||
docs/diagrams/**/*.gif
|
||||
docs/diagrams/**/*.webp
|
||||
docs/diagrams/**/*.svg
|
||||
# Note: `*.md` matches the root only (Go filepath.Match does not cross /),
|
||||
# so nested docs/**/*.md is implicitly kept without a re-include rule.
|
||||
*.md
|
||||
|
||||
410
.env.example
410
.env.example
@@ -6,6 +6,7 @@
|
||||
# │ Reference: docs/ENVIRONMENT.md for full details and usage scenarios. │
|
||||
# └─────────────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 1. REQUIRED SECRETS — Must be set before first run!
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -36,8 +37,6 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# Base directory for all persistent data (SQLite DB, logs, backups).
|
||||
# Used by: src/lib/db/core.ts — resolves the SQLite database file path.
|
||||
# Default: ~/.omniroute/ | Override for Docker or custom installations.
|
||||
# Hint: When running in Docker, consider mounting a host directory here for data persistence across container restarts
|
||||
# also if you want to share the same database as "npm run dev" use "./data"
|
||||
# DATA_DIR=/var/lib/omniroute
|
||||
|
||||
# Encryption key for SQLite database encryption at rest.
|
||||
@@ -61,6 +60,7 @@ DISABLE_SQLITE_AUTO_BACKUP=false
|
||||
# Default: redis://localhost:6379 (or redis://redis:6379 in Docker)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 3. NETWORK & PORTS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -77,66 +77,10 @@ PORT=20128
|
||||
# API_HOST=0.0.0.0
|
||||
# DASHBOARD_PORT=20128
|
||||
|
||||
# Port for the real-time WebSocket live monitoring server.
|
||||
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
|
||||
# Default: 20129
|
||||
# LIVE_WS_PORT=20129
|
||||
|
||||
# Bind address for the live WebSocket server.
|
||||
# Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN —
|
||||
# remember to also configure LIVE_WS_ALLOWED_ORIGINS when doing so.
|
||||
# LIVE_WS_HOST=127.0.0.1
|
||||
|
||||
# Comma-separated extra origins allowed to open a live WebSocket. The
|
||||
# loopback dashboard origins are already permitted by default; use this
|
||||
# var when fronting the server with a domain (e.g. https://omni.local).
|
||||
# LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com
|
||||
|
||||
# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
|
||||
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
|
||||
# OMNIROUTE_DISABLE_LIVE_WS=0
|
||||
|
||||
# Enable the real-time dashboard WebSocket server.
|
||||
# Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs
|
||||
# Default: ON. Set to 0 or false to disable startup of the live WS server.
|
||||
# Combine with LIVE_WS_HOST / LIVE_WS_ALLOWED_ORIGINS above when exposing
|
||||
# beyond loopback.
|
||||
# OMNIROUTE_ENABLE_LIVE_WS=1
|
||||
|
||||
# Per-(token,IP) relay rate limit, requests/minute. In-memory, per instance.
|
||||
# 0 or negative disables the IP-dimension gate (per-token DB limit still applies).
|
||||
# Default: 30
|
||||
# Used by: src/app/api/v1/relay/chat/completions/route.ts
|
||||
# RELAY_IP_PER_MINUTE=30
|
||||
|
||||
# Use Turbopack in local dev. Next 16.2.4 can fail to compile next/font/google
|
||||
# through the custom dev runner without this on Windows.
|
||||
OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
|
||||
# OMNIROUTE_SKIP_DB_HEALTHCHECK=1
|
||||
|
||||
# Interval (ms) for the background credential health check scheduler.
|
||||
# Default: 300000 (5 minutes). Minimum: 10000 (10 seconds).
|
||||
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/scheduler.ts
|
||||
# CREDENTIAL_HEALTH_CHECK_INTERVAL=300000
|
||||
|
||||
# TTL (ms) for cached credential health status.
|
||||
# Default: 300000 (5 minutes).
|
||||
# Used by: open-sse/config/constants.ts, src/lib/credentialHealth/cache.ts
|
||||
# CREDENTIAL_HEALTH_CACHE_TTL=300000
|
||||
|
||||
# Set to 1 or true to disable background periodic testing of provider connections.
|
||||
# Default: false
|
||||
# Used by: src/lib/credentialHealth/scheduler.ts
|
||||
# OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK=false
|
||||
|
||||
# Set to "true" to emit `[ProxyFetch]` debug logs from the Vercel relay path
|
||||
# in open-sse/utils/proxyFetch.ts. Off by default to avoid leaking routing
|
||||
# hints in production logs.
|
||||
# OMNIROUTE_PROXY_FETCH_DEBUG=true
|
||||
|
||||
# Docker production port mappings (docker-compose.prod.yml only).
|
||||
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
|
||||
# PROD_DASHBOARD_PORT=20130
|
||||
@@ -157,11 +101,6 @@ OMNIROUTE_USE_TURBOPACK=1
|
||||
# Values: production | development | Default: production
|
||||
NODE_ENV=production
|
||||
|
||||
# Container runtime — controls startup script behavior (permissions, advice).
|
||||
# Values: docker | podman | Default: docker
|
||||
# Set to "podman" when running under rootless Podman so the entrypoint
|
||||
# gives the correct fix instructions (podman unshare chown vs sudo chown).
|
||||
CONTAINER_HOST=docker
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 4. SECURITY & AUTHENTICATION
|
||||
@@ -200,14 +139,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Generate: openssl rand -base64 32
|
||||
# OMNIROUTE_WS_BRIDGE_SECRET=
|
||||
|
||||
# Per-process secret that proves the trusted peer-IP stamp came from OmniRoute's
|
||||
# own HTTP server (scripts/dev/peer-stamp.mjs). The custom server stamps the real
|
||||
# TCP peer IP as `<token>|<ip>`; the authz middleware trusts the locality only
|
||||
# when the token matches. Used by: src/server/authz/policies/management.ts.
|
||||
# Auto-generated per boot — leave UNSET in normal use. Only set it to pin a fixed
|
||||
# value across processes (e.g. a multi-process setup that must share the stamp).
|
||||
# OMNIROUTE_PEER_STAMP_TOKEN=
|
||||
|
||||
# Comma-separated API key IDs that skip request logging (GDPR/compliance).
|
||||
# Used by: src/lib/compliance/index.ts — suppresses logs for specific keys.
|
||||
# NO_LOG_API_KEY_IDS=key_abc123,key_def456
|
||||
@@ -241,6 +172,7 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# When unset, OmniRoute uses the per-feature defaults. Set to "false"/"0" to disable.
|
||||
# OUTBOUND_SSRF_GUARD_ENABLED=true
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 5. INPUT SANITIZATION & PII PROTECTION (FASE-01)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -259,20 +191,13 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
|
||||
# PII_REDACTION_ENABLED=false
|
||||
|
||||
# Minimum streaming window size for PII detection (bytes). Default: 200.
|
||||
# Used by: src/lib/streamingPiiTransform.ts.
|
||||
# PII_WINDOW_SIZE=200
|
||||
|
||||
# Test bypass: allow setting PII_WINDOW_SIZE below minimum. Default: false.
|
||||
# Used by: src/lib/streamingPiiTransform.ts.
|
||||
# PII_TEST_BYPASS_MIN_WINDOW=false
|
||||
|
||||
# ── Response-Side: PII Sanitizer ──
|
||||
# Scans LLM responses for leaked PII before returning to the client.
|
||||
# Used by: src/lib/piiSanitizer.ts
|
||||
# PII_RESPONSE_SANITIZATION=false
|
||||
# PII_RESPONSE_SANITIZATION_MODE=redact # redact = mask PII | warn = log only | block = drop response
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 6. TOOL & ROUTING POLICIES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -292,11 +217,6 @@ ALLOW_API_KEY_REVEAL=false
|
||||
# Default: 5000 | Minimum: 1000
|
||||
# OMNIROUTE_PAYLOAD_RULES_RELOAD_MS=5000
|
||||
|
||||
# Prefer Claude Code OAuth for unprefixed Claude-family model IDs such as
|
||||
# claude-sonnet-4-6 or newly released IDs like claude-fable-5.
|
||||
# Used by: open-sse/services/model.ts. Explicit provider prefixes still win.
|
||||
# Default: false
|
||||
# OMNIROUTE_PREFER_CLAUDE_CODE_FOR_UNPREFIXED_CLAUDE_MODELS=false
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 7. URLS & CLOUD SYNC
|
||||
@@ -368,8 +288,7 @@ NEXT_PUBLIC_CLOUD_URL=
|
||||
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
|
||||
#OMNIROUTE_GEMINI_CLI_USAGE_URL=https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist
|
||||
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
|
||||
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
|
||||
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 8. OUTBOUND PROXY (Upstream Provider Calls)
|
||||
@@ -393,10 +312,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Used by: open-sse/executors — replaces Node.js default TLS fingerprint.
|
||||
# ENABLE_TLS_FINGERPRINT=true
|
||||
|
||||
# Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors.
|
||||
# Only enable for local debugging or trusted MITM/corporate proxy environments.
|
||||
# Used by: open-sse/services/claudeTurnstileSolver.ts
|
||||
# OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS=false
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 9. CLI TOOL INTEGRATION
|
||||
@@ -427,11 +342,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# CLI_QODER_BIN=qoder
|
||||
# CLI_QWEN_BIN=qwen
|
||||
|
||||
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
|
||||
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
|
||||
# on Windows (%LOCALAPPDATA%\hermes); defaults to ~/.hermes when unset.
|
||||
# Used by: src/lib/cli-helper/config-generator/hermesHome.ts
|
||||
# HERMES_HOME=~/.hermes
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 10. INTERNAL AGENT & MCP INTEGRATIONS
|
||||
@@ -482,17 +392,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
|
||||
# Default: 70
|
||||
PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
|
||||
|
||||
# Gap (ms) between consecutive OAuth quota fetches in a bulk provider-limits sync.
|
||||
# OAuth providers are fetched one at a time with this spacing so a single host
|
||||
# never bursts simultaneous usage/refresh requests to the same upstream. Set to 0
|
||||
# to opt out (restores fully concurrent fetches). Default: 1500
|
||||
PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
|
||||
# Delay (ms) before refreshing provider limits after a real usage event (e.g. a
|
||||
# completed request). Gives the upstream quota API time to register the consumption
|
||||
# before the dashboard polls. Default: 5000
|
||||
#PROVIDER_LIMITS_POST_USAGE_REFRESH_DELAY_MS=5000
|
||||
|
||||
# Disable all background services (sync, pricing, model refresh).
|
||||
# Used by: src/instrumentation-node.ts, src/lib/initCloudSync.ts
|
||||
# Useful for: CI builds, test environments, or resource-constrained containers.
|
||||
@@ -516,13 +415,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
#OMNIROUTE_SPEND_FLUSH_INTERVAL_MS=60000
|
||||
#OMNIROUTE_SPEND_MAX_BUFFER_SIZE=1000
|
||||
|
||||
# Batch request processor retry, backoff, and concurrency settings.
|
||||
# Used by: open-sse/services/batchProcessor.ts. Defaults shown.
|
||||
#BATCH_RETRY_DURATION_MS=86400000
|
||||
#BATCH_BACKOFF_BASE_MS=5000
|
||||
#BATCH_BACKOFF_MAX_MS=3600000
|
||||
#BATCH_MAX_CONCURRENT=1
|
||||
|
||||
# Config hot-reload polling interval (ms). Default: 5000.
|
||||
# Used by: src/lib/config/hotReload.ts. Lower than 1000ms is rejected.
|
||||
#OMNIROUTE_CONFIG_HOT_RELOAD_MS=5000
|
||||
@@ -539,10 +431,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Used by: scripts/postinstall.mjs.
|
||||
#OMNIROUTE_SKIP_POSTINSTALL=0
|
||||
|
||||
# Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests).
|
||||
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0.
|
||||
#OMNIROUTE_SKIP_DB_HEALTHCHECK=0
|
||||
|
||||
# Force a DB healthcheck regardless of cadence. Default: 0.
|
||||
# Used by: src/lib/db/core.ts::shouldRunDbHealthCheck().
|
||||
#OMNIROUTE_FORCE_DB_HEALTHCHECK=0
|
||||
@@ -568,11 +456,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
|
||||
# Default: empty (use upstream-reported credits).
|
||||
#ANTIGRAVITY_CREDITS=
|
||||
|
||||
# Override the path to the Antigravity CLI (agy) token file read by the
|
||||
# "auto-detect local login" import. Used by:
|
||||
# src/app/api/providers/agy-auth/apply-local/route.ts — for non-standard installs.
|
||||
# Default: ~/.gemini/antigravity-cli/antigravity-oauth-token
|
||||
#AGY_TOKEN_FILE=
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 11. OAUTH PROVIDER CREDENTIALS
|
||||
@@ -590,23 +473,6 @@ CLAUDE_OAUTH_CLIENT_ID=9d1c250a-e61b-44d9-88ed-5944d1962f5e
|
||||
# ── Codex / OpenAI ──
|
||||
CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
|
||||
|
||||
# Milliseconds to wait between consecutive Codex token refreshes.
|
||||
# Used by: open-sse/services/refreshSerializer.ts. Default: 0 (no spacing).
|
||||
# CODEX_REFRESH_SPACING_MS=0
|
||||
|
||||
# ── Trae (ByteDance) ──
|
||||
# Trae stream idle timeout (ms). Default: 300000 (5 min).
|
||||
# Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_STREAM_TIMEOUT_MS=300000
|
||||
|
||||
# Trae OAuth token override. Used by: open-sse/executors/trae.ts.
|
||||
# TRAE_TOKEN=
|
||||
|
||||
# ── The Old LLM (theoldllm) ──
|
||||
# Playwright navigation timeout (ms) for the browser-backed token capture.
|
||||
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
|
||||
# THEOLDLLM_NAV_TIMEOUT_MS=30000
|
||||
|
||||
# ── Gemini / Gemini CLI / Antigravity / Windsurf (all Google-based) ──
|
||||
# These providers ship public OAuth client_id/secret values (or Firebase Web
|
||||
# keys) embedded in their public CLIs/binaries. Defaults are baked into the
|
||||
@@ -725,6 +591,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# CLI_USER_ID= # legacy alias for OMNIROUTE_USER_ID
|
||||
# SERVER_URL= # legacy alias for OMNIROUTE_SERVER
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 12. PROVIDER USER-AGENT OVERRIDES
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -733,32 +600,20 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
|
||||
# Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup.
|
||||
# Update these when providers release new CLI versions to avoid blocks.
|
||||
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)"
|
||||
|
||||
# Disable the deterministic tool-name cloak applied on both Anthropic-bound paths
|
||||
# (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) —
|
||||
# third-party-harness tool names are aliased to
|
||||
# Claude Code canonical or PascalCase forms so Anthropic does not refuse the
|
||||
# stream with a misleading 400 out-of-extra-usage placeholder. Set to true to
|
||||
# forward the original names verbatim (debugging only).
|
||||
# CLAUDE_DISABLE_TOOL_NAME_CLOAK=false
|
||||
CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)"
|
||||
CLAUDE_USER_AGENT="claude-cli/2.1.143 (external, cli)"
|
||||
CODEX_USER_AGENT="codex-cli/0.131.0 (Windows 10.0.26200; x64)"
|
||||
GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0"
|
||||
ANTIGRAVITY_USER_AGENT="antigravity/1.23.2 darwin/arm64"
|
||||
KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
|
||||
# Optional override for the Kiro social device-code OAuth clientId. Kiro's
|
||||
# device endpoint accepts any non-empty string and behaves like a User-Agent
|
||||
# rather than a secret. Only override if AWS ever starts enforcing this field.
|
||||
# Used by: src/lib/oauth/constants/oauth.ts (KIRO_CONFIG.socialClientId).
|
||||
# KIRO_OAUTH_CLIENT_ID=kiro-cli
|
||||
QODER_USER_AGENT="Qoder-Cli"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
|
||||
QWEN_USER_AGENT="QwenCode/0.15.9 (linux; x64)"
|
||||
CURSOR_USER_AGENT="Cursor/3.4"
|
||||
GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
|
||||
# Override Codex client version sent in headers independently of the
|
||||
# CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts.
|
||||
# CODEX_CLIENT_VERSION=0.132.0
|
||||
# CODEX_CLIENT_VERSION=0.131.0
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection)
|
||||
@@ -788,6 +643,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
#KIMI_CLI_VERSION=1.36.0
|
||||
#KIMI_CODING_DEVICE_ID=
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 14. API KEY PROVIDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -810,6 +666,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# OpenAI/Mistral/Together/Fireworks/NVIDIA configured via Dashboard → Providers
|
||||
# also work for embeddings.
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 15. TIMEOUT SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -849,29 +706,6 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# OMNIROUTE_CLAUDE_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_CLAUDE_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Perplexity TLS sidecar (Firefox-fingerprinted client) ──
|
||||
# Used by: open-sse/services/perplexityTlsClient.ts — wire-level timeout for
|
||||
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
|
||||
# layered on top of it when the native library is wedged.
|
||||
# OMNIROUTE_PPLX_TLS_TIMEOUT_MS=30000
|
||||
# OMNIROUTE_PPLX_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Grok web TLS sidecar (Chrome-fingerprinted client) ──
|
||||
# Used by: open-sse/services/grokTlsClient.ts — wire-level timeout for the
|
||||
# bogdanfinn/tls-client koffi binding and the JS-side grace window layered on
|
||||
# top of it when the native library is wedged.
|
||||
# OMNIROUTE_GROK_TLS_TIMEOUT_MS=60000
|
||||
# OMNIROUTE_GROK_TLS_GRACE_MS=10000
|
||||
|
||||
# ── Browser-backed web-cookie chat (Playwright shared pool) ──
|
||||
# Used by: open-sse/services/browserPool.ts + browserBackedChat.ts. The shared
|
||||
# browser pool warms a headless context for web-cookie providers (e.g. claude-web)
|
||||
# that need a real browser to satisfy anti-bot challenges. Set OMNIROUTE_BROWSER_POOL=off
|
||||
# to fully disable the pool; set WEB_COOKIE_USE_BROWSER=1 to opt a web-cookie chat
|
||||
# request into the browser-backed path.
|
||||
# OMNIROUTE_BROWSER_POOL=on
|
||||
# WEB_COOKIE_USE_BROWSER=0
|
||||
|
||||
# ── Circuit breaker thresholds and reset windows ──
|
||||
# Used by: open-sse/config/constants.ts → src/lib/resilience/settings.ts.
|
||||
# Defaults match historical PROVIDER_PROFILES values (post-scaling for
|
||||
@@ -892,8 +726,8 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
|
||||
|
||||
# ── API Bridge (/v1 proxy server) ──
|
||||
# API_BRIDGE_PROXY_TIMEOUT_MS=600000 # Proxy hop timeout (default: 10min)
|
||||
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=600000 # Overall server request timeout (default: 10min)
|
||||
# API_BRIDGE_PROXY_TIMEOUT_MS=30000 # Proxy hop timeout (default: 30s)
|
||||
# API_BRIDGE_SERVER_REQUEST_TIMEOUT_MS=300000 # Overall server request timeout
|
||||
# API_BRIDGE_SERVER_HEADERS_TIMEOUT_MS=60000 # Time to send response headers
|
||||
# API_BRIDGE_SERVER_KEEPALIVE_TIMEOUT_MS=5000 # Keep-alive idle timeout
|
||||
# API_BRIDGE_SERVER_SOCKET_TIMEOUT_MS=0 # Raw socket timeout (0 = disabled)
|
||||
@@ -904,6 +738,7 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0"
|
||||
# Default: 30000 (30 seconds)
|
||||
# SHUTDOWN_TIMEOUT_MS=30000
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 16. LOGGING
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -977,30 +812,15 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 100000
|
||||
# PROXY_LOGS_TABLE_MAX_ROWS=100000
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 17. MEMORY OPTIMIZATION (Low-RAM / Docker)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
# Node.js V8 heap limit in MB, passed to the server via --max-old-space-size.
|
||||
# Used by the standalone launcher (Docker CMD) and `omniroute serve`.
|
||||
# Clamped to [64, 16384]. Default: 512 (safe for a 1 GB / 1 core VPS). Size it to
|
||||
# roughly half the box's RAM, leaving the rest for native memory (better-sqlite3,
|
||||
# buffers — ~300 MB) and the OS:
|
||||
# 1 GB RAM → 512 (default)
|
||||
# 2 GB RAM → 1024
|
||||
# 4 GB RAM → 2048
|
||||
# In a memory-capped container, set this EXPLICITLY: Node reads the HOST's RAM,
|
||||
# not the cgroup limit, so leaving it to a RAM heuristic can oversize the heap and
|
||||
# get the container OOM-killed. (#2939)
|
||||
# OMNIROUTE_MEMORY_MB=512
|
||||
|
||||
# Heap-pressure shed threshold (MB) — chatCore returns 503 when V8 heapUsed exceeds
|
||||
# it, to avoid hard OOM under concurrent large-context load.
|
||||
# LEAVE UNSET: it now AUTO-CALIBRATES to 85% of the actual V8 heap ceiling, so it
|
||||
# tracks OMNIROUTE_MEMORY_MB above and never sits below the ~260 MB runtime baseline
|
||||
# (a fixed 200 here used to reject every request). Used by: open-sse/utils/heapPressure.ts.
|
||||
# Override only to hand-tune for a known workload.
|
||||
# HEAP_PRESSURE_THRESHOLD_MB=
|
||||
# Node.js V8 heap limit in MB.
|
||||
# Used by: Docker entrypoint — sets --max-old-space-size.
|
||||
# Default: 256 (Docker) | system default (npm)
|
||||
# OMNIROUTE_MEMORY_MB=256
|
||||
|
||||
# ── CLI helpers (bin/cli/) ──
|
||||
# Override UI language for CLI output. Accepts BCP-47 locale (e.g. en, pt-BR).
|
||||
@@ -1026,10 +846,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree.
|
||||
# OMNIROUTE_PLUGIN_PATH=
|
||||
|
||||
# Allow plugins to request the 'exec' permission (spawn child processes from the
|
||||
# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only).
|
||||
# OMNIROUTE_PLUGINS_ALLOW_EXEC=0
|
||||
|
||||
# ── Prompt cache (system prompt deduplication) ──
|
||||
# Used by: open-sse/services — caches identical system prompts across requests.
|
||||
# PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50)
|
||||
@@ -1056,6 +872,7 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/utils/usageTracking.ts
|
||||
# USAGE_TOKEN_BUFFER=100
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 18. PRICING SYNC
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1071,6 +888,7 @@ APP_LOG_TO_FILE=true
|
||||
# Comma-separated data sources. Default: litellm
|
||||
# PRICING_SYNC_SOURCES=litellm
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 19. MODEL SYNC (Dev)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1079,6 +897,7 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 86400 (24 hours)
|
||||
# MODELS_DEV_SYNC_INTERVAL=86400
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 20. PROVIDER-SPECIFIC SETTINGS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1089,14 +908,6 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 86400000 (24 hours)
|
||||
# OPENROUTER_CATALOG_TTL_MS=86400000
|
||||
|
||||
# ── Model catalog response shape ──
|
||||
# Include display-friendly name fields in /v1/models responses.
|
||||
# Disable for clients that expect model IDs only.
|
||||
# Defined in: src/shared/constants/featureFlagDefinitions.ts
|
||||
# Used by: src/app/api/v1/models/catalog.ts
|
||||
# Default: true
|
||||
# MODEL_CATALOG_INCLUDE_NAMES=true
|
||||
|
||||
# ── NanoBanana (Image Generation) ──
|
||||
# Polling config for async image generation jobs.
|
||||
# Used by: open-sse/handlers/imageGeneration.ts
|
||||
@@ -1140,21 +951,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/shared/utils/featureFlags.ts
|
||||
# ENABLE_CC_COMPATIBLE_PROVIDER=false
|
||||
|
||||
# ── 9router embedded service ──
|
||||
# Override the host/port where the embedded 9router instance listens.
|
||||
# Rarely needed — defaults match the bootstrap config (127.0.0.1:20130).
|
||||
# Used by: open-sse/executors/ninerouter.ts
|
||||
# NINEROUTER_HOST=127.0.0.1
|
||||
# NINEROUTER_PORT=20130
|
||||
|
||||
# ── Embedded service WebSocket proxy ──
|
||||
# Standalone WebSocket proxy that tunnels WS connections to embedded services.
|
||||
# Binds to loopback by default. Only change EMBED_WS_PROXY_HOST if you know
|
||||
# what you are doing — exposing this to non-loopback bypasses local-only policy.
|
||||
# Used by: src/lib/services/embedWsProxy.ts
|
||||
# EMBED_WS_PROXY_HOST=127.0.0.1
|
||||
# EMBED_WS_PROXY_PORT=20131
|
||||
|
||||
# ── CLIProxyAPI bridge (legacy) ──
|
||||
# Connection settings for external CLIProxyAPI instances.
|
||||
# Used by: open-sse/executors/cliproxyapi.ts
|
||||
@@ -1167,6 +963,7 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
|
||||
# LOCAL_HOSTNAMES=omlx,mlx-audio
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 21. PROXY HEALTH
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1188,29 +985,11 @@ APP_LOG_TO_FILE=true
|
||||
# Accepted values: true|1|on (force on), false|0|off (force off), unset (use Dashboard).
|
||||
# RATE_LIMIT_AUTO_ENABLE=
|
||||
|
||||
# Provider cooldown tracking: minimum time (ms) before a failed provider/connection
|
||||
# can be retried. Prevents subsequent requests from re-walking failing providers.
|
||||
# Scaled exponentially: minCooldown * 2^(failures-1), capped at maxRetryCooldownMs.
|
||||
# Used by: open-sse/services/providerCooldownTracker.ts
|
||||
# PROVIDER_COOLDOWN_MIN_MS=5000
|
||||
|
||||
# Provider cooldown tracking: maximum time (ms) before a failed provider/connection
|
||||
# is retried regardless. Hard cap to prevent providers from being skipped indefinitely.
|
||||
# Used by: open-sse/services/providerCooldownTracker.ts
|
||||
# PROVIDER_COOLDOWN_MAX_MS=300000
|
||||
|
||||
# Enable/disable global provider cooldown tracking. Opt-in: this global
|
||||
# cross-request cooldown overlaps the existing Connection Cooldown / Provider
|
||||
# Circuit Breaker layers, so it is OFF by default. When disabled, only the
|
||||
# existing per-request/per-connection cooldown state is used (previous behavior).
|
||||
# Used by: open-sse/services/providerCooldownTracker.ts
|
||||
# Accepted values: true|1|on (enable). Unset or anything else = disabled (default).
|
||||
# PROVIDER_COOLDOWN_ENABLED=true
|
||||
|
||||
# Stagger interval (ms) between provider token healthchecks at startup.
|
||||
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
|
||||
# HEALTHCHECK_STAGGER_MS=3000
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 22. DEBUGGING
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1220,13 +999,6 @@ APP_LOG_TO_FILE=true
|
||||
# CURSOR_STREAM_DEBUG is kept as a backward-compatible alias.
|
||||
# Used by: open-sse/executors/cursor.ts
|
||||
# CURSOR_DEBUG=1
|
||||
|
||||
# Enable verbose trace logging for OmniRoute internals.
|
||||
# Used by: open-sse/handlers/chatCore.ts.
|
||||
# OMNIROUTE_TRACE=true
|
||||
|
||||
# Standard DEBUG flag (same effect as OMNIROUTE_TRACE).
|
||||
# DEBUG=true
|
||||
# CURSOR_STREAM_DEBUG=1
|
||||
|
||||
# When CURSOR_DEBUG=1, also append raw decoded chunks to this file path.
|
||||
@@ -1236,16 +1008,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: open-sse/executors/cursor.ts.
|
||||
# CURSOR_STREAM_TIMEOUT_MS=300000
|
||||
|
||||
# Cursor tool-commit directive toggle. Default-on: when a request declares
|
||||
# tools, a directive is prepended so composer-2.5 reliably issues tool calls
|
||||
# instead of narrating intent. Set to 0 to disable.
|
||||
# Used by: open-sse/executors/cursor.ts.
|
||||
# CURSOR_TOOL_DIRECTIVE=1
|
||||
|
||||
# Per-image fetch timeout (ms) for remote image_url vision input. Default: 15000.
|
||||
# Used by: open-sse/utils/cursorImages.ts.
|
||||
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
|
||||
|
||||
# Cursor state DB path override (for cursor version detection).
|
||||
# Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically.
|
||||
# CURSOR_STATE_DB_PATH=
|
||||
@@ -1270,6 +1032,7 @@ APP_LOG_TO_FILE=true
|
||||
# Enable E2E test mode — relaxes auth and enables test harness hooks.
|
||||
# NEXT_PUBLIC_OMNIROUTE_E2E_MODE=true
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 23. GITHUB INTEGRATION (Issue Reporting)
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1287,6 +1050,7 @@ APP_LOG_TO_FILE=true
|
||||
# GITHUB_ISSUES_TOKEN when unset.
|
||||
# GITHUB_TOKEN=
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 24. PROVIDER QUOTAS, TUNNELS & SANDBOXED SKILLS
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1319,13 +1083,6 @@ APP_LOG_TO_FILE=true
|
||||
# Used by: src/app/api/providers/command-code/auth/shared.ts.
|
||||
# COMMAND_CODE_CALLBACK_PORT=
|
||||
|
||||
# ── Command Code CLI version header ──
|
||||
# Value sent as the x-command-code-version header to the Command Code upstream.
|
||||
# Overrides the built-in default; bump if the upstream requires a newer CLI version.
|
||||
# Used by: open-sse/executors/commandCode.ts
|
||||
# Default: 0.33.2
|
||||
# COMMAND_CODE_VERSION=0.33.2
|
||||
|
||||
# ── MITM debug proxy (development only) ──
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
@@ -1339,35 +1096,6 @@ APP_LOG_TO_FILE=true
|
||||
# ONEPROXY_MAX_PROXIES=500
|
||||
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
|
||||
|
||||
# ── Free Proxy Pool (1proxy source) ──
|
||||
# Used by: src/lib/freeProxyProviders/oneproxy.ts
|
||||
# Set FREE_PROXY_1PROXY_ENABLED=false to disable this source.
|
||||
# FREE_PROXY_1PROXY_ENABLED=true
|
||||
# FREE_PROXY_1PROXY_API_URL=https://1proxy-api.aitradepulse.com/api/v1/proxies/advanced
|
||||
# FREE_PROXY_1PROXY_MAX=500
|
||||
# FREE_PROXY_1PROXY_MIN_QUALITY=50
|
||||
|
||||
# ── Free Proxy Pool (Proxifly source) ──
|
||||
# Used by: src/lib/freeProxyProviders/proxifly.ts
|
||||
# Enabled by default; set to false to disable.
|
||||
# FREE_PROXY_PROXIFLY_ENABLED=true
|
||||
# FREE_PROXY_PROXIFLY_QUANTITY=100
|
||||
# FREE_PROXY_PROXIFLY_ANONYMITY=elite
|
||||
|
||||
# ── Free Proxy Pool (IPLocate source) ──
|
||||
# Used by: src/lib/freeProxyProviders/iplocate.ts
|
||||
# Opt-in only; must set FREE_PROXY_IPLOCATE_ENABLED=true to activate.
|
||||
# FREE_PROXY_IPLOCATE_ENABLED=false
|
||||
# FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols
|
||||
|
||||
# ── Vercel Relay ──
|
||||
# Used by: src/app/api/settings/proxy/vercel-deploy/route.ts
|
||||
# Hides the "Deploy Relay" button when set to false.
|
||||
# NEXT_PUBLIC_VERCEL_RELAY_ENABLED=true
|
||||
# VERCEL_API_BASE=https://api.vercel.com
|
||||
# Default project name pre-filled in the Vercel Relay deploy modal.
|
||||
# NEXT_PUBLIC_VERCEL_RELAY_DEFAULT_PROJECT=omniroute-relay
|
||||
|
||||
# ── Tailscale tunnel binaries ──
|
||||
# Optional explicit paths to tailscale/tailscaled binaries used by the
|
||||
# dashboard's tunnel manager. Used by: src/lib/tailscaleTunnel.ts.
|
||||
@@ -1398,6 +1126,7 @@ APP_LOG_TO_FILE=true
|
||||
# SKILLS_SANDBOX_NETWORK_ENABLED=0
|
||||
# SKILLS_ALLOWED_SANDBOX_IMAGES=
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# 25. TEST & E2E
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -1419,12 +1148,6 @@ APP_LOG_TO_FILE=true
|
||||
# Disable the OAuth token healthcheck loop during tests (default: true).
|
||||
# OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK=true
|
||||
|
||||
# Exclude specific providers from the PROACTIVE token-refresh sweep (comma-separated,
|
||||
# case-insensitive). Targeted alternative to OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: keeps
|
||||
# rotating-cascade providers (Codex/OpenAI share one Auth0 family) on the reactive 401
|
||||
# path only, while short-TTL providers like Kimi-coding keep being refreshed proactively.
|
||||
# OMNIROUTE_HEALTHCHECK_SKIP_PROVIDERS=codex,openai
|
||||
|
||||
# Silence healthcheck noise in Playwright stdout (default: true).
|
||||
# OMNIROUTE_HIDE_HEALTHCHECK_LOGS=true
|
||||
|
||||
@@ -1450,31 +1173,6 @@ APP_LOG_TO_FILE=true
|
||||
# Number of parallel translation requests (default 4).
|
||||
# OMNIROUTE_TRANSLATION_CONCURRENCY=4
|
||||
|
||||
# ─── Cloud Sync hardening (v3.8.6) ──────────────────────────────────────────
|
||||
# Shared secret used to verify the HMAC-SHA256 of the Cloud sync response body
|
||||
# (the Cloud endpoint must sign each response with the same secret and place
|
||||
# the hex digest in the X-Cloud-Sig header). When unset, v3.8.6 logs a warning
|
||||
# but accepts unsigned responses for back-compat. v3.9 will make this required.
|
||||
# OMNIROUTE_CLOUD_SYNC_SECRET=
|
||||
#
|
||||
# Set to "true" to allow the Cloud Sync endpoint to overwrite local OAuth
|
||||
# tokens (accessToken / refreshToken / providerSpecificData). Default OFF —
|
||||
# only non-credential metadata is synced. See docs/security/SOCKET_DEV_FINDINGS.md §5.
|
||||
# OMNIROUTE_CLOUD_SYNC_SECRETS=false
|
||||
|
||||
# ─── Zed import legacy compat (v3.8.6) ──────────────────────────────────────
|
||||
# Set to "true" to fall back to the v3.8.5 one-step "import everything from
|
||||
# the keychain" behaviour. Default OFF — the new 2-step confirmation flow
|
||||
# requires `confirmedAccounts` in the request body. See SOCKET_DEV_FINDINGS.md §2.
|
||||
# OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP=false
|
||||
|
||||
# ─── Build profile (build-time only) ────────────────────────────────────────
|
||||
# Set to "minimal" before `npm run build` to physically remove four optional
|
||||
# privileged modules (MITM cert install, Zed keychain import, Cloud Sync,
|
||||
# 9router installer) from the standalone bundle. The resulting artifact is
|
||||
# intended to be published as `omniroute-secure`. See SECURITY.md.
|
||||
# OMNIROUTE_BUILD_PROFILE=full
|
||||
|
||||
# Electron smoke harness (used by scripts/dev/smoke-electron-packaged.mjs).
|
||||
# ELECTRON_SMOKE_URL=http://127.0.0.1:20128/login
|
||||
# ELECTRON_SMOKE_TIMEOUT_MS=45000
|
||||
@@ -1483,51 +1181,3 @@ APP_LOG_TO_FILE=true
|
||||
# ELECTRON_SMOKE_DATA_DIR=
|
||||
# ELECTRON_SMOKE_KEEP_DATA=0
|
||||
# ELECTRON_SMOKE_STREAM_LOGS=0
|
||||
|
||||
# Playground Studio
|
||||
# Default model used by the improve-prompt route (optional; falls back to model in request body).
|
||||
PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL=
|
||||
# Maximum number of parallel compare columns in the Compare tab.
|
||||
PLAYGROUND_COMPARE_MAX_COLUMNS=4
|
||||
# Memory engine (plan 21)
|
||||
# MEMORY_EMBEDDING_CACHE_TTL_MS=300000 # default 5 min
|
||||
# MEMORY_EMBEDDING_CACHE_MAX=1000 # default 1000 entries
|
||||
# MEMORY_TRANSFORMERS_MODEL=Xenova/all-MiniLM-L6-v2
|
||||
# MEMORY_STATIC_MODEL=minishlab/potion-base-8M # HF repo id (download once)
|
||||
# MEMORY_STATIC_CACHE_DIR= # default <DATA_DIR>/embeddings
|
||||
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
|
||||
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
|
||||
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
|
||||
# AgentBridge + Traffic Inspector (Group A)
|
||||
|
||||
# AgentBridge
|
||||
AGENTBRIDGE_UPSTREAM_CA_CERT=
|
||||
|
||||
# Inspector
|
||||
INSPECTOR_BUFFER_SIZE=1000
|
||||
INSPECTOR_HTTP_PROXY_PORT=8080
|
||||
INSPECTOR_HTTP_PROXY_AUTOSTART=false
|
||||
INSPECTOR_TLS_INTERCEPT=false
|
||||
INSPECTOR_SYSTEM_PROXY_GUARD_MINUTES=30
|
||||
INSPECTOR_MAX_BODY_KB=1024
|
||||
INSPECTOR_MASK_SECRETS=true
|
||||
INSPECTOR_LLM_HOSTS_EXTRA=
|
||||
INSPECTOR_INTERNAL_INGEST_TOKEN=
|
||||
# Quota Sharing (Group B — planos 16+22)
|
||||
QUOTA_STORE_DRIVER=sqlite # sqlite | redis
|
||||
# QUOTA_STORE_REDIS_URL= # ex.: redis://localhost:6379 (apenas quando driver=redis)
|
||||
# QUOTA_SATURATION_THRESHOLD=0.5 # 0..1; >= threshold ativa modo strict (sem empréstimo)
|
||||
# QUOTA_SOFT_DEPRIORITIZE_FACTOR=0.7 # 0..1; multiplicador do score quando soft policy ativa
|
||||
# QUOTA_CONSUMPTION_RETENTION_DAYS=14 # GC de buckets quota_consumption.updated_at antigos
|
||||
|
||||
# ─── OpenCode config regeneration (scripts/ad-hoc/regen-opencode-config.ts) ───
|
||||
# Base URL of the OmniRoute instance to query for /v1/models when regenerating
|
||||
# an opencode.json with accurate limit.context values. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Default: http://localhost:20128
|
||||
# OMNIROUTE_URL=
|
||||
# API key to authenticate against the OmniRoute /v1/models endpoint. Falls back
|
||||
# to OPENCODE_API_KEY when unset. Used by: scripts/ad-hoc/regen-opencode-config.ts.
|
||||
# OMNIROUTE_KEY=
|
||||
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
|
||||
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
|
||||
# OPENCODE_API_KEY=
|
||||
|
||||
37
.github/workflows/build-fork.yml
vendored
37
.github/workflows/build-fork.yml
vendored
@@ -1,30 +1,23 @@
|
||||
name: Publish Fork Image to GHCR
|
||||
name: Build Fork Image (ghcr.io)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
env:
|
||||
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build and Push Fork Image
|
||||
if: github.repository == 'kang-heewon/OmniRoute'
|
||||
name: Build and Push to ghcr.io
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -36,30 +29,14 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-
|
||||
type=ref,event=tag
|
||||
labels: |
|
||||
org.opencontainers.image.title=omniroute
|
||||
org.opencontainers.image.description=Unified AI proxy/router — fork image
|
||||
org.opencontainers.image.url=https://github.com/kang-heewon/OmniRoute
|
||||
org.opencontainers.image.source=https://github.com/kang-heewon/OmniRoute
|
||||
org.opencontainers.image.licenses=MIT
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: linux/amd64,linux/arm64
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: |
|
||||
ghcr.io/gi99lin/omniroute:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
253
.github/workflows/ci.yml
vendored
253
.github/workflows/ci.yml
vendored
@@ -24,12 +24,6 @@ jobs:
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
||||
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
@@ -43,64 +37,9 @@ jobs:
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:docs-sync
|
||||
- run: npm run typecheck:core
|
||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
||||
# Run informationally for now — many pre-existing call sites still need
|
||||
# explicit annotations; track in a dedicated follow-up.
|
||||
- run: npm run typecheck:noimplicit:core
|
||||
continue-on-error: true
|
||||
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
- uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
- run: npm run quality:collect
|
||||
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
||||
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
||||
# coverage mergeado). Tamanho de arquivo e duplicação têm gates dedicados.
|
||||
- name: Ratchet check
|
||||
run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
# Catraca de duplicação (jscpd@4 sobre src+open-sse). Roda neste job (paralelo)
|
||||
# para não pesar no caminho crítico do lint.
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Complexity ratchet
|
||||
run: npm run check:complexity
|
||||
- name: Append summary
|
||||
if: always()
|
||||
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- name: Upload ratchet report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: quality-ratchet
|
||||
path: .artifacts/quality-ratchet.md
|
||||
if-no-files-found: warn
|
||||
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
@@ -113,21 +52,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:docs-all
|
||||
# Previously-orphaned contract gates (existed as files, never wired anywhere).
|
||||
# All exit 0 today: cli-i18n is a hard gate, openapi-coverage is a ratchet
|
||||
# (floor ~36), openapi-security-tiers is advisory (Hard Rules #15/#17).
|
||||
- name: CLI i18n consistency
|
||||
run: npm run check:cli-i18n
|
||||
- name: OpenAPI route coverage (ratchet)
|
||||
run: npm run check:openapi-coverage
|
||||
- name: OpenAPI security-tier consistency (advisory)
|
||||
run: npm run check:openapi-security-tiers
|
||||
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
|
||||
run: npm run check:openapi-routes
|
||||
- name: Doc /api refs resolve to real routes (anti-hallucination)
|
||||
run: npm run check:docs-symbols
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
- name: i18n translation drift (strict)
|
||||
run: node scripts/i18n/check-translation-drift.mjs
|
||||
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
@@ -139,7 +65,7 @@ jobs:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
|
||||
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80
|
||||
|
||||
i18n-matrix:
|
||||
name: Build language matrix
|
||||
@@ -195,9 +121,6 @@ jobs:
|
||||
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
|
||||
- name: Validate source changes include tests
|
||||
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
|
||||
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
|
||||
- name: Detect test-masking (weakened assertions)
|
||||
run: npm run check:test-masking
|
||||
- name: Publish PR test policy summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -217,21 +140,6 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- name: Archive Next.js build for E2E shards
|
||||
# Use tar so the archive preserves paths relative to CWD (.build/next/...).
|
||||
# upload-artifact path-stripping is ambiguous when exclude patterns are used;
|
||||
# an explicit tar avoids the double-nesting issue (.build/next/next/...).
|
||||
run: |
|
||||
tar -czf /tmp/e2e-build.tar.gz \
|
||||
--exclude='.build/next/standalone/node_modules' \
|
||||
--exclude='.build/next/cache' \
|
||||
.build/next
|
||||
- name: Upload Next.js build for E2E shards
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-next-build
|
||||
path: /tmp/e2e-build.tar.gz
|
||||
retention-days: 1
|
||||
|
||||
package-artifact:
|
||||
name: Package Artifact
|
||||
@@ -247,11 +155,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
# build:cli runs a clean build into .build/next and assembles dist/
|
||||
# For release builds prefer: npm run build:release (clean rebuild + HEAD sentinel)
|
||||
- run: npm run build:cli
|
||||
- name: Assert dist/server.js exists
|
||||
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
|
||||
- run: npm run check:pack-artifact
|
||||
|
||||
electron-package-smoke:
|
||||
@@ -283,14 +187,14 @@ jobs:
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
name: Unit Tests (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -303,32 +207,7 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
test-vitest:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
needs: build
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never
|
||||
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
|
||||
- run: npm run test:vitest
|
||||
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
|
||||
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
|
||||
# fixes the components/tests; then drop continue-on-error to make it blocking.
|
||||
- run: npm run test:vitest:ui
|
||||
continue-on-error: true
|
||||
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
|
||||
|
||||
node-24-compat:
|
||||
name: Node 24 Compatibility (${{ matrix.shard }}/2)
|
||||
@@ -352,7 +231,7 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
|
||||
|
||||
node-26-compat:
|
||||
name: Node 26 Compatibility (${{ matrix.shard }}/2)
|
||||
@@ -376,17 +255,13 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run build
|
||||
- run: node --import tsx --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- run: node --import tsx --test --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/unit/*.test.ts
|
||||
|
||||
test-coverage-shard:
|
||||
name: Coverage Shard (${{ matrix.shard }}/8)
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
timeout-minutes: 30
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -399,82 +274,8 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- name: Run c8 over shard ${{ matrix.shard }}/8
|
||||
run: |
|
||||
rm -rf coverage-shard coverage-shard-report
|
||||
# `--temp-directory` (writable via NODE_V8_COVERAGE) is what the merge
|
||||
# job reads with `c8 report --temp-directory ...`. Using `--output-dir`
|
||||
# only produces the final json *report* and leaves the raw v8 files in
|
||||
# `coverage/tmp`, so uploading `coverage-shard/` was empty. Pin the temp
|
||||
# dir so the raw coverage files live there and the artifact upload picks
|
||||
# them up regardless of `--test-force-exit` timing.
|
||||
npx c8 \
|
||||
--temp-directory=coverage-shard \
|
||||
--reports-dir=coverage-shard-report \
|
||||
--reporter=json \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
node --max-old-space-size=4096 --import tsx --test --test-force-exit --test-concurrency=4 \
|
||||
--test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,compression,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
- name: Upload raw shard coverage
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: coverage-shard-${{ matrix.shard }}
|
||||
path: coverage-shard/*.json
|
||||
if-no-files-found: error
|
||||
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
needs: test-coverage-shard
|
||||
if: ${{ always() && needs.test-coverage-shard.result == 'success' }}
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Download all shard coverage
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: coverage-shard-*
|
||||
path: coverage-shards/
|
||||
merge-multiple: true
|
||||
- name: Merge + report + gate
|
||||
# Merging 8 shards of raw v8 coverage is memory-heavy; the default Node
|
||||
# heap OOMs (exit 134). Raise it for the c8 merge/report step.
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=6144
|
||||
run: |
|
||||
mkdir -p coverage
|
||||
if [ ! -d coverage-shards ] || ! find coverage-shards -maxdepth 1 -type f -name '*.json' | grep -q .; then
|
||||
echo "::error::No raw coverage shard data was downloaded."
|
||||
find . -maxdepth 3 -type f | sort
|
||||
exit 1
|
||||
fi
|
||||
# Gate aligned to the project's local coverage bar: `npm run test:coverage`
|
||||
# gates at 60/60/60/60, so CI must match it (the previous CI floor of 40
|
||||
# silently undershot the local bar — a real drift). Real merged coverage is
|
||||
# ~79/79/82/75, so 60 is a conservative floor with headroom; the Fase-4
|
||||
# coverage ratchet (quality-baseline.json) layers "must not drop vs baseline"
|
||||
# on top of this floor.
|
||||
npx c8 report \
|
||||
--temp-directory coverage-shards \
|
||||
--reports-dir coverage \
|
||||
--reporter=text-summary \
|
||||
--reporter=html \
|
||||
--reporter=json-summary \
|
||||
--reporter=lcov \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
--check-coverage \
|
||||
--statements 60 --lines 60 --functions 60 --branches 60
|
||||
- name: Run coverage gate
|
||||
run: npm run test:coverage
|
||||
- name: Build coverage summary
|
||||
if: always()
|
||||
run: |
|
||||
@@ -613,18 +414,14 @@ jobs:
|
||||
}
|
||||
|
||||
test-e2e:
|
||||
name: E2E Tests (${{ matrix.shard }}/9)
|
||||
name: E2E Tests (${{ matrix.shard }}/6)
|
||||
runs-on: ubuntu-latest
|
||||
# Build artifact from the `build` job is downloaded instead of rebuilding
|
||||
# (~5min saved per shard). 9 shards (up from 6) reduces tests per shard by
|
||||
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
||||
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
|
||||
timeout-minutes: 45
|
||||
timeout-minutes: 20
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4, 5, 6, 7, 8, 9]
|
||||
shard: [1, 2, 3, 4, 5, 6]
|
||||
env:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -638,23 +435,9 @@ jobs:
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: playwright-chromium-${{ runner.os }}-
|
||||
- run: npx playwright install --with-deps chromium
|
||||
- name: Download Next.js build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: e2e-next-build
|
||||
path: /tmp/
|
||||
- name: Extract Next.js build and restore standalone node_modules
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
cp -r node_modules .build/next/standalone/node_modules
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
||||
- run: npm run build
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/6
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
|
||||
43
.github/workflows/claude-code-review.yml
vendored
Normal file
43
.github/workflows/claude-code-review.yml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: "https://github.com/anthropics/claude-code.git"
|
||||
plugins: "code-review@claude-code-plugins"
|
||||
prompt: "/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}"
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
77
.github/workflows/deploy-vps.yml
vendored
77
.github/workflows/deploy-vps.yml
vendored
@@ -17,82 +17,27 @@ jobs:
|
||||
name: Deploy OmniRoute to VPS
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check VPS SSH reachability from runner
|
||||
id: reach
|
||||
env:
|
||||
# Pass the host via env (never interpolate a secret straight into the
|
||||
# script body) so /dev/tcp gets a shell variable, not inlined text.
|
||||
VPS_HOST: ${{ secrets.VPS_HOST }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# A GitHub-hosted runner can only deploy when it can actually open a TCP
|
||||
# connection to the VPS SSH port. The Local VPS lives on a private LAN and
|
||||
# the Akamai host firewalls :22 to known IPs, so the runner is routinely
|
||||
# unable to reach it (`dial tcp ***:22: i/o timeout`). Treat "unreachable
|
||||
# from the runner" as a SKIP — the real deploys are run manually from an
|
||||
# allowed network via the deploy-vps-local / deploy-vps-akamai skills — so
|
||||
# an unreachable host no longer red-fails every release/push pipeline.
|
||||
# When the host IS reachable, the deploy step below still runs in full and
|
||||
# its health gate surfaces any genuine deploy failure.
|
||||
if timeout 15 bash -c 'exec 3<>"/dev/tcp/${VPS_HOST}/22"' 2>/dev/null; then
|
||||
echo "reachable=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ VPS_HOST:22 reachable from the runner — proceeding with deploy."
|
||||
else
|
||||
echo "reachable=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning title=Auto-deploy skipped::VPS_HOST:22 is not reachable from this GitHub runner (private LAN / firewalled). Deploy manually with the deploy-vps-local or deploy-vps-akamai skill."
|
||||
fi
|
||||
|
||||
- name: Deploy via SSH
|
||||
if: steps.reach.outputs.reachable == 'true'
|
||||
uses: appleboy/ssh-action@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
host: ${{ secrets.VPS_HOST }}
|
||||
username: ${{ secrets.VPS_USER }}
|
||||
key: ${{ secrets.VPS_SSH_KEY }}
|
||||
port: 22
|
||||
timeout: 60s
|
||||
command_timeout: 15m
|
||||
timeout: 30s
|
||||
command_timeout: 5m
|
||||
script: |
|
||||
set -euo pipefail
|
||||
|
||||
echo "=== Updating OmniRoute ==="
|
||||
npm install -g omniroute@latest
|
||||
INSTALLED_VERSION=$(omniroute --version 2>/dev/null | tr -d '[:space:]' || echo "unknown")
|
||||
echo "Installed CLI version: $INSTALLED_VERSION"
|
||||
npm install -g omniroute@latest 2>&1
|
||||
INSTALLED_VERSION=$(omniroute --version 2>/dev/null || echo "unknown")
|
||||
echo "Installed version: $INSTALLED_VERSION"
|
||||
|
||||
# Recreate the PM2 process instead of `pm2 restart`. A bare restart
|
||||
# re-runs whatever script path was saved earlier; after the build-output
|
||||
# reorg (app/ -> dist/, .next -> .build/next) a process pinned to the old
|
||||
# app/server-ws.mjs path can no longer start, and the node process dies
|
||||
# while PM2 still reports "online" — so the box never binds :20128.
|
||||
# Always launch via the `omniroute` bin so .env is loaded and the dist/
|
||||
# layout is resolved correctly.
|
||||
echo "=== (Re)creating PM2 process via bin ==="
|
||||
pm2 delete omniroute 2>/dev/null || true
|
||||
pm2 start omniroute --name omniroute -- --port 20128
|
||||
echo "=== Restarting PM2 ==="
|
||||
pm2 restart omniroute || pm2 start omniroute --name omniroute -- --port 20128
|
||||
pm2 save
|
||||
|
||||
# Health gate: fail the deploy unless the box actually reports healthy.
|
||||
# Poll /api/monitoring/health for "status":"healthy" (a deeper signal than
|
||||
# a static page 200 — it confirms the app booted, not just that a port is
|
||||
# bound). Boot can take a while after a native-module/build-layout change,
|
||||
# so poll up to ~3min before giving up.
|
||||
echo "=== Health Check (gates the deploy) ==="
|
||||
ok=0
|
||||
for i in $(seq 1 36); do
|
||||
BODY=$(curl -sf -m 5 http://localhost:20128/api/monitoring/health 2>/dev/null || true)
|
||||
if printf '%s' "$BODY" | grep -q '"status":"healthy"'; then
|
||||
ok=1
|
||||
echo "✅ /api/monitoring/health -> healthy (attempt $i) — version $INSTALLED_VERSION"
|
||||
break
|
||||
fi
|
||||
echo "… not healthy yet (attempt $i/36), retrying in 5s"
|
||||
sleep 5
|
||||
done
|
||||
if [ "$ok" != "1" ]; then
|
||||
echo "❌ Health check failed — /api/monitoring/health never reported healthy after ~3min"
|
||||
echo "--- recent PM2 logs ---"
|
||||
pm2 logs omniroute --lines 40 --nostream || true
|
||||
exit 1
|
||||
fi
|
||||
echo "=== Health Check ==="
|
||||
sleep 3
|
||||
curl -sf http://localhost:20128/api/settings > /dev/null && echo "✅ OmniRoute is healthy" || echo "❌ Health check failed"
|
||||
echo "=== Deploy complete ==="
|
||||
|
||||
294
.github/workflows/docker-publish.yml
vendored
294
.github/workflows/docker-publish.yml
vendored
@@ -8,35 +8,23 @@ on:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
- ".github/workflows/**"
|
||||
# Use 'released' instead of 'published' so editing/re-publishing old releases
|
||||
# does NOT re-trigger this workflow. 'released' fires only on the initial
|
||||
# release publication (and pre-release → release transition).
|
||||
release:
|
||||
types: [released]
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version tag to build (e.g. 3.8.4)"
|
||||
description: "Version tag to build (e.g. 2.6.0)"
|
||||
required: true
|
||||
type: string
|
||||
promote_latest:
|
||||
description: "Also tag :latest (only if this is the highest semver)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
prepare:
|
||||
name: Resolve Docker release metadata
|
||||
docker:
|
||||
name: Build and Push Docker (multi-arch)
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
promote_latest: ${{ steps.version.outputs.promote_latest }}
|
||||
skip: ${{ steps.version.outputs.skip }}
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
steps:
|
||||
@@ -44,104 +32,9 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
# Need full tag history for semver comparison when deciding :latest.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Resolve version, latest-promotion, and skip flag
|
||||
id: version
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PROMOTE_INPUT: ${{ inputs.promote_latest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) Resolve version string from the trigger (all inputs come via env).
|
||||
case "$EVENT_NAME" in
|
||||
workflow_dispatch)
|
||||
VERSION="${INPUT_VERSION#v}"
|
||||
;;
|
||||
push)
|
||||
if [ "$REF_TYPE" = "tag" ]; then
|
||||
VERSION="${REF_NAME#v}"
|
||||
else
|
||||
# Push to main → build & tag as `main` only. Never touch :latest.
|
||||
VERSION="main"
|
||||
fi
|
||||
;;
|
||||
release)
|
||||
VERSION="${REF_NAME#v}"
|
||||
;;
|
||||
*)
|
||||
VERSION="${REF_NAME#v}"
|
||||
;;
|
||||
esac
|
||||
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
|
||||
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
|
||||
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 2) Decide whether to promote :latest.
|
||||
PROMOTE="false"
|
||||
if [ "$VERSION" = "main" ]; then
|
||||
PROMOTE="false"
|
||||
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
||||
echo "Pre-release identifier detected — skipping :latest."
|
||||
PROMOTE="false"
|
||||
elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then
|
||||
PROMOTE="${PROMOTE_INPUT:-false}"
|
||||
else
|
||||
git fetch --tags --quiet || true
|
||||
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
||||
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
||||
PROMOTE="true"
|
||||
else
|
||||
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
|
||||
fi
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 3) Skip if this exact version is already published in Docker Hub.
|
||||
# `main` is always rebuilt (mutable floating tag).
|
||||
SKIP="false"
|
||||
if [ "$VERSION" != "main" ]; then
|
||||
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
|
||||
SKIP="true"
|
||||
fi
|
||||
fi
|
||||
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Publishing diegosouzapw/omniroute:$VERSION (promote_latest=$PROMOTE, skip=$SKIP)"
|
||||
|
||||
build:
|
||||
name: Build Docker (${{ matrix.platform }})
|
||||
needs: prepare
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-24.04
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
arch: arm64
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
- name: Set up QEMU (for multi-arch builds)
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
@@ -159,172 +52,41 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push platform image by digest
|
||||
id: build
|
||||
- name: Extract version from release tag or input
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
VERSION="${{ inputs.version }}"
|
||||
else
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
VERSION="${VERSION#v}"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Publishing Docker image: $IMAGE_NAME:$VERSION"
|
||||
|
||||
- name: Build and push multi-arch image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-base
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-${{ matrix.arch }},mode=max
|
||||
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
|
||||
${{ env.IMAGE_NAME }}:latest
|
||||
ghcr.io/diegosouzapw/omniroute:${{ steps.version.outputs.version }}
|
||||
ghcr.io/diegosouzapw/omniroute:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Build and push WEB platform image by digest
|
||||
id: build-web
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
target: runner-web
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
tags: |
|
||||
${{ env.IMAGE_NAME }}
|
||||
${{ env.GHCR_IMAGE_NAME }}
|
||||
cache-from: type=gha,scope=docker-web-${{ matrix.arch }}
|
||||
cache-to: type=gha,scope=docker-web-${{ matrix.arch }},mode=max
|
||||
no-cache: false
|
||||
env:
|
||||
DOCKER_BUILDKIT_INLINE_CACHE: 1
|
||||
|
||||
- name: Export digests
|
||||
env:
|
||||
DIGEST_BASE: ${{ steps.build.outputs.digest }}
|
||||
DIGEST_WEB: ${{ steps.build-web.outputs.digest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/digests/base /tmp/digests/web
|
||||
touch "/tmp/digests/base/${DIGEST_BASE#sha256:}"
|
||||
touch "/tmp/digests/web/${DIGEST_WEB#sha256:}"
|
||||
|
||||
- name: Upload base digests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-base-${{ matrix.arch }}
|
||||
path: /tmp/digests/base/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload web digests
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: digests-web-${{ matrix.arch }}
|
||||
path: /tmp/digests/web/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
name: Publish multi-arch manifests
|
||||
needs:
|
||||
- prepare
|
||||
- build
|
||||
if: needs.prepare.outputs.skip != 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IMAGE_NAME: diegosouzapw/omniroute
|
||||
GHCR_IMAGE_NAME: ghcr.io/diegosouzapw/omniroute
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
PROMOTE_LATEST: ${{ needs.prepare.outputs.promote_latest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event_name == 'workflow_dispatch' && format('refs/tags/v{0}', inputs.version) || '' }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download base digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: digests-base-*
|
||||
path: /tmp/digests/base
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download web digests
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: digests-web-*
|
||||
path: /tmp/digests/web
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Docker Hub manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
fi
|
||||
local refs=()
|
||||
while IFS= read -r digest_file; do
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||
}
|
||||
|
||||
create_manifest "${IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
|
||||
- name: Create GHCR manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
create_manifest() {
|
||||
local image="$1" suffix="$2" dir="$3"
|
||||
local tags=(-t "${image}:${VERSION}${suffix}")
|
||||
if [ "$PROMOTE_LATEST" = "true" ]; then
|
||||
tags+=(-t "${image}:latest${suffix}")
|
||||
fi
|
||||
local refs=()
|
||||
while IFS= read -r digest_file; do
|
||||
refs+=("${image}@sha256:$(basename "$digest_file")")
|
||||
done < <(find "$dir" -type f | sort)
|
||||
if [ "${#refs[@]}" -eq 0 ]; then
|
||||
echo "No image digests in $dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||
}
|
||||
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "" /tmp/digests/base
|
||||
create_manifest "${GHCR_IMAGE_NAME}" "-web" /tmp/digests/web
|
||||
|
||||
- name: Inspect image
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
run: |
|
||||
docker buildx imagetools inspect "${IMAGE_NAME}:${VERSION}"
|
||||
docker buildx imagetools inspect "${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Update Docker Hub description
|
||||
# Only refresh README/description when we actually promote :latest
|
||||
# (avoids overwriting from main pushes or back-fill builds).
|
||||
if: needs.prepare.outputs.promote_latest == 'true'
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
|
||||
14
.github/workflows/electron-release.yml
vendored
14
.github/workflows/electron-release.yml
vendored
@@ -104,7 +104,6 @@ jobs:
|
||||
- name: Build Next.js standalone
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
NODE_OPTIONS: "--max_old_space_size=6144"
|
||||
run: npm run build
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
@@ -136,16 +135,9 @@ jobs:
|
||||
|
||||
- name: Smoke packaged Electron app
|
||||
if: matrix.platform != 'linux'
|
||||
# Best-effort smoke on Windows + macos-arm64:
|
||||
# - Windows: requestSingleInstanceLock() fails due to USERPROFILE
|
||||
# sanitization needed for the build step.
|
||||
# - macos-arm64: the headless GitHub arm64 runner crashes Electron's GPU
|
||||
# process (gpu_process_host exit_code=15 → network service crash →
|
||||
# "No rendezvous client, terminating process"), so the app can't bind
|
||||
# 127.0.0.1:20128 in time. The identical bundle is smoke-gated on
|
||||
# macos-intel + linux, so packaging is still verified per-OS; we don't
|
||||
# let the arm64 runner's GPU flakiness block the desktop release.
|
||||
continue-on-error: ${{ matrix.platform == 'windows' || matrix.platform == 'macos-arm64' }}
|
||||
# Windows CI: requestSingleInstanceLock() fails due to USERPROFILE
|
||||
# sanitization needed for the build step. Smoke is best-effort there.
|
||||
continue-on-error: ${{ matrix.platform == 'windows' }}
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
ELECTRON_SMOKE_STREAM_LOGS: "1"
|
||||
|
||||
125
.github/workflows/lock-released-branch.yml
vendored
125
.github/workflows/lock-released-branch.yml
vendored
@@ -1,125 +0,0 @@
|
||||
name: Lock released branch
|
||||
|
||||
# Two responsibilities (defense in depth — Hard Rule #18 enforcement):
|
||||
#
|
||||
# 1. `on: release: published` — when a GitHub Release publishes tag v3.X.Y,
|
||||
# apply branch protection (lock_branch + enforce_admins) to release/v3.X.Y
|
||||
# so no further commits can land on a shipped version. To reopen later:
|
||||
# gh api -X DELETE repos/<owner>/<repo>/branches/release/<tag>/protection
|
||||
#
|
||||
# 2. `on: push: branches: ['release/v*']` — verify that no push lands on a
|
||||
# release/* branch whose matching tag already exists. This is the preventive
|
||||
# guard: if the lock didn't apply (workflow bug, missing PAT, race), this
|
||||
# job FAILS the push run so the operator gets paged immediately.
|
||||
#
|
||||
# `permissions:` cannot grant the `Administration` scope to GITHUB_TOKEN — that
|
||||
# scope only exists on PATs. Set BRANCH_LOCK_TOKEN as a repo secret pointing to
|
||||
# a PAT/fine-grained token with `Administration: read & write`. Without it, the
|
||||
# lock step will fail loudly (which is what we want — silent failure caused the
|
||||
# v3.8.3 incident on 2026-05-26 where 6 commits landed post-release).
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
branches:
|
||||
- "release/v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag of the released version (e.g. v3.8.2)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Job 1 — Lock the release branch when a Release is published.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
lock-branch:
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Lock release/<tag> branch
|
||||
env:
|
||||
# Administration scope is required to PUT branch protection. Default
|
||||
# GITHUB_TOKEN cannot do this — operator must provision BRANCH_LOCK_TOKEN.
|
||||
GH_TOKEN: ${{ secrets.BRANCH_LOCK_TOKEN }}
|
||||
TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${GH_TOKEN}" ]; then
|
||||
echo "::error::BRANCH_LOCK_TOKEN secret is not set. Create a PAT with Administration:write and add it as repo secret."
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "${TAG}" ]; then
|
||||
echo "::error::No tag provided; cannot determine release branch."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BRANCH="release/${TAG}"
|
||||
echo "Target branch: ${BRANCH} (repo: ${REPO})"
|
||||
|
||||
if ! gh api "repos/${REPO}/branches/${BRANCH}" >/dev/null 2>&1; then
|
||||
echo "::warning::Branch ${BRANCH} not found — nothing to lock."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Applying lock_branch protection to ${BRANCH}..."
|
||||
gh api -X PUT "repos/${REPO}/branches/${BRANCH}/protection" --input - <<'JSON'
|
||||
{
|
||||
"required_status_checks": null,
|
||||
"enforce_admins": true,
|
||||
"required_pull_request_reviews": null,
|
||||
"restrictions": null,
|
||||
"lock_branch": true,
|
||||
"allow_force_pushes": false,
|
||||
"allow_deletions": false
|
||||
}
|
||||
JSON
|
||||
|
||||
LOCKED=$(gh api "repos/${REPO}/branches/${BRANCH}/protection" \
|
||||
--jq '.lock_branch.enabled')
|
||||
if [ "${LOCKED}" != "true" ]; then
|
||||
echo "::error::Failed to confirm lock on ${BRANCH} (lock_branch=${LOCKED})."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ ${BRANCH} is now locked (read-only)."
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# Job 2 — Preventive guard: fail if a push lands on release/vX.Y.Z whose
|
||||
# tag already exists. This catches the case where the lock didn't apply
|
||||
# (PAT missing, race window, workflow bug) and pages the operator.
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
guard-no-push-after-release:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Reject push if matching release tag exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# Extract version from ref: release/v3.8.3 -> v3.8.3
|
||||
if [[ ! "${REF}" =~ ^release/(v[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
|
||||
echo "Ref ${REF} does not match release/vX.Y.Z — nothing to guard."
|
||||
exit 0
|
||||
fi
|
||||
TAG="${BASH_REMATCH[1]}"
|
||||
echo "Checking if tag ${TAG} already exists on ${REPO}..."
|
||||
|
||||
if gh api "repos/${REPO}/git/refs/tags/${TAG}" >/dev/null 2>&1; then
|
||||
echo "::error::Hard Rule #18 violation — push to ${REF} but tag ${TAG} is already released."
|
||||
echo "::error::Hotfixes for a released version must go on a NEW branch: release/v$(echo "${TAG#v}" | awk -F. '{$3=$3+1; print $1"."$2"."$3}' OFS=.)"
|
||||
echo "::error::To undo this push: revert the offending commits, or contact an admin to lock the branch if it wasn't already."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ No release tag for ${TAG} yet — push is OK."
|
||||
193
.github/workflows/npm-publish.yml
vendored
193
.github/workflows/npm-publish.yml
vendored
@@ -1,11 +1,8 @@
|
||||
name: Publish to npm
|
||||
|
||||
on:
|
||||
# 'released' (not 'published') so editing/re-publishing old releases does NOT
|
||||
# re-trigger this workflow. Pairs with the semver guard below as defense in
|
||||
# depth against accidental dist-tag clobbering by old releases.
|
||||
release:
|
||||
types: [released]
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
@@ -13,15 +10,13 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "npm dist-tag (auto / latest / next / historic)"
|
||||
description: "npm dist-tag (latest / next)"
|
||||
required: false
|
||||
default: "auto"
|
||||
default: "latest"
|
||||
type: choice
|
||||
options:
|
||||
- auto
|
||||
- latest
|
||||
- next
|
||||
- historic
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
@@ -29,9 +24,9 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
tag:
|
||||
description: "npm dist-tag (auto / latest / next / historic)"
|
||||
description: "npm dist-tag (latest / next)"
|
||||
required: false
|
||||
default: "auto"
|
||||
default: "latest"
|
||||
type: string
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
@@ -48,13 +43,10 @@ env:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
environment: NPM_TOKEN
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
# Need full tag history to compare against highest semver when
|
||||
# deciding whether this release should claim dist-tag `latest`.
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
@@ -65,148 +57,77 @@ jobs:
|
||||
- name: Install dependencies (skip scripts to avoid heavy build)
|
||||
run: npm install --ignore-scripts --no-audit --no-fund
|
||||
|
||||
- name: Resolve version, dist-tag and skip flag
|
||||
- name: Resolve version and dist-tag
|
||||
id: resolve
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REF_NAME: ${{ github.ref_name }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION="${{ inputs.version }}"
|
||||
TAG="${{ inputs.tag }}"
|
||||
|
||||
# 1) Resolve VERSION from the trigger (all inputs come via env).
|
||||
VERSION="${INPUT_VERSION:-}"
|
||||
if [ -z "$VERSION" ] && [ "$EVENT_NAME" = "release" ]; then
|
||||
VERSION="$REF_NAME"
|
||||
fi
|
||||
VERSION="${VERSION#v}"
|
||||
if ! printf '%s' "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+([.-][A-Za-z0-9.-]+)?$'; then
|
||||
echo "Refusing to publish unsafe VERSION value: $VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2) Resolve dist-tag.
|
||||
# - explicit 'latest'/'next'/'historic' is honored
|
||||
# - 'auto' (or empty): pre-release identifiers → 'next';
|
||||
# stable versions → 'latest' only if VERSION is the highest
|
||||
# stable semver among `v*` tags (otherwise → 'historic').
|
||||
REQUESTED_TAG="${INPUT_TAG:-auto}"
|
||||
TAG="$REQUESTED_TAG"
|
||||
if [ "$TAG" = "auto" ] || [ -z "$TAG" ]; then
|
||||
if printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
||||
TAG="next"
|
||||
else
|
||||
git fetch --tags --quiet || true
|
||||
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
|
||||
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
|
||||
TAG="latest"
|
||||
else
|
||||
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Using dist-tag 'historic' to avoid clobbering @latest."
|
||||
TAG="historic"
|
||||
fi
|
||||
if [ -z "$VERSION" ]; then
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# 3) Skip-if-already-published. NOTE: do NOT pass `--silent` to
|
||||
# `npm view` — it suppresses stdout and breaks the grep, which
|
||||
# caused old releases (3.2.8) to be re-published and steal
|
||||
# dist-tag `latest`. See incident notes in CHANGELOG.
|
||||
PUBLISHED="$(npm view "omniroute@${VERSION}" version 2>/dev/null || true)"
|
||||
SKIP="false"
|
||||
if [ "$PUBLISHED" = "$VERSION" ]; then
|
||||
echo "⚠️ omniroute@${VERSION} is already on npm — skipping publish."
|
||||
SKIP="true"
|
||||
fi
|
||||
# Strip v prefix if present
|
||||
VERSION="${VERSION#v}"
|
||||
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Resolved omniroute@$VERSION dist-tag=$TAG skip=$SKIP"
|
||||
# Default dist-tag logic
|
||||
if [ -z "$TAG" ]; then
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
TAG="next"
|
||||
else
|
||||
TAG="latest"
|
||||
fi
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "📦 Publishing omniroute@$VERSION with tag=$TAG"
|
||||
|
||||
- name: Sync package.json version
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
run: |
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
npm version "${{ steps.resolve.outputs.version }}" --no-git-tag-version --allow-same-version
|
||||
|
||||
- name: Build CLI bundle (standalone app)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
run: npm run build:cli
|
||||
|
||||
- name: Validate npm package artifact
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
run: npm run check:pack-artifact
|
||||
|
||||
- name: Publish to npm
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, `npm publish --tag historic` will
|
||||
# NOT promote it to `@latest`.
|
||||
npm publish --access public --tag "$TAG"
|
||||
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
|
||||
|
||||
- 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
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Install plugin dependencies
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
- name: Build plugin
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm run clean && npm run build
|
||||
|
||||
- name: Test plugin
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm test
|
||||
|
||||
- name: Publish @omniroute/opencode-plugin to npm
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
# Same hardened skip-check as the main job (no --silent flag).
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" = "$PKG_VERSION" ]; then
|
||||
echo "⚠️ ${PKG_NAME}@${PKG_VERSION} is already published on npm — skipping."
|
||||
VERSION="${{ steps.resolve.outputs.version }}"
|
||||
TAG="${{ steps.resolve.outputs.tag }}"
|
||||
# Check if this version is already published — skip instead of failing with E403
|
||||
if npm view "omniroute@${VERSION}" version --silent 2>/dev/null | grep -q "^${VERSION}$"; then
|
||||
echo "⚠️ Version ${VERSION} is already published on npm — skipping."
|
||||
exit 0
|
||||
fi
|
||||
npm publish --access public --ignore-scripts
|
||||
echo "✅ Published ${PKG_NAME}@${PKG_VERSION}"
|
||||
if [ "$TAG" = "latest" ]; then
|
||||
npm publish --access public
|
||||
else
|
||||
npm publish --access public --tag "$TAG"
|
||||
fi
|
||||
echo "✅ Published omniroute@$VERSION (tag: $TAG)"
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Publish to GitHub Packages
|
||||
run: |
|
||||
VERSION="${{ steps.resolve.outputs.version }}"
|
||||
TAG="${{ steps.resolve.outputs.tag }}"
|
||||
|
||||
echo "Configuring for GitHub Packages..."
|
||||
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
|
||||
npm pkg set name="@diegosouzapw/omniroute"
|
||||
|
||||
if [ "$TAG" = "latest" ]; then
|
||||
npm publish --registry=https://npm.pkg.github.com || echo "⚠️ Version ${VERSION} might already be published on GitHub."
|
||||
else
|
||||
npm publish --registry=https://npm.pkg.github.com --tag "$TAG" || echo "⚠️ Version ${VERSION} might already be published on GitHub."
|
||||
fi
|
||||
echo "✅ Action finished for GitHub Packages"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
62
.github/workflows/opencode-plugin-ci.yml
vendored
62
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -1,62 +0,0 @@
|
||||
name: opencode-plugin CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
pull_request:
|
||||
branches: [main, release/v3.8.2]
|
||||
paths:
|
||||
- "@omniroute/opencode-plugin/**"
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test (Node ${{ matrix.node }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: ["22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- run: npm test
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-plugin/package-lock.json"
|
||||
- run: npm install --no-audit --no-fund
|
||||
- run: npm run build
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: opencode-plugin-dist
|
||||
path: "@omniroute/opencode-plugin/dist"
|
||||
retention-days: 7
|
||||
6
.github/workflows/opencode-provider-ci.yml
vendored
6
.github/workflows/opencode-provider-ci.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
node: ["20", "22", "24"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -47,14 +47,14 @@ jobs:
|
||||
needs: test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: "@omniroute/opencode-provider/package-lock.json"
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- uses: actions/upload-artifact@v7
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: opencode-provider-dist
|
||||
path: "@omniroute/opencode-provider/dist"
|
||||
|
||||
117
.gitignore
vendored
117
.gitignore
vendored
@@ -4,37 +4,12 @@
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
_cache/
|
||||
_ideia/
|
||||
_mono_repo/
|
||||
_references/
|
||||
_tasks/
|
||||
.agents/**
|
||||
.claude/**
|
||||
.gemini/**
|
||||
.config/**
|
||||
.data/**
|
||||
.logs/**
|
||||
.tests/**
|
||||
.coverage/**
|
||||
coverage/
|
||||
.dist/**
|
||||
.next/**
|
||||
.build/**
|
||||
.out/**
|
||||
|
||||
|
||||
# Memory Bank and Cursor rules (local-only AI agent context)
|
||||
memory-bank/
|
||||
.cursor/rules/core.mdc
|
||||
.cursor/rules/memory-bank.mdc
|
||||
|
||||
# Claude Code local state — runtime files only; shared commands at .claude/commands/ are tracked
|
||||
.claude/scheduled_tasks.lock
|
||||
.claude/scheduled_tasks/
|
||||
.claude/sessions/
|
||||
.claude/state.json
|
||||
.claude/settings.local.json
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never commit)
|
||||
/_*/
|
||||
@@ -44,15 +19,32 @@ docs/new-features/
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
*.map
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.data/
|
||||
.next-playwright/
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
coverage**
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
/app
|
||||
cloud/*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# Obsidian sync plugin — committed for community distribution
|
||||
!obsidian-plugin/
|
||||
obsidian-plugin/node_modules/
|
||||
|
||||
# Serena AI assistant config (local-only tool, not project code)
|
||||
.serena/
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
@@ -63,9 +55,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -108,22 +97,15 @@ clipr/
|
||||
app.log
|
||||
*.tgz
|
||||
.gh-discussions.json
|
||||
deploy.sh
|
||||
docker-compose.minimal.yml
|
||||
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
.app-build-backup-*/
|
||||
backup/
|
||||
|
||||
# Build intermediates (.build/) and shippable standalone (dist/).
|
||||
# These are fully reproducible from source; never committed.
|
||||
# Layer 1: Next.js now writes to .build/next (was .next); assembled bundle → dist/
|
||||
# (Previously /app/ was the standalone output; renamed to /dist/ in Layer 1.)
|
||||
/.build/
|
||||
/dist/
|
||||
/.next/
|
||||
# Production standalone build (created by scripts/prepublish.mjs)
|
||||
# Conflicts with Next.js App Router detection in dev (root app/ shadows src/app/)
|
||||
# npm publish still includes it via package.json "files" field
|
||||
/app/
|
||||
|
||||
# Electron
|
||||
electron/dist-electron/
|
||||
@@ -138,6 +120,9 @@ vscode-extension/
|
||||
*.sqlite-wal
|
||||
*.sqlite-journal
|
||||
|
||||
# Compiled npm-package build artifact (not source, should not be in git)
|
||||
/app
|
||||
|
||||
# IDEA
|
||||
.idea/
|
||||
|
||||
@@ -166,8 +151,8 @@ bun.lock
|
||||
# Private environment variables for .http-client
|
||||
http-client.private.env.json
|
||||
|
||||
# Note: _ideia/ (feature-triage drafts) is fully covered by the /_*/ rule above
|
||||
# and kept as a separate local-only git repo. Never committed to OmniRoute.
|
||||
# Feature-triage ephemeral artifact (regenerated each run)
|
||||
_ideia/_triage.json
|
||||
|
||||
# i18n audit artifact (generated by scripts/i18n/audit-dashboard-pages.mjs)
|
||||
scripts/i18n/_audit.json
|
||||
@@ -176,39 +161,5 @@ scripts/i18n/_pending-keys.json
|
||||
# Private workflow / skill / command implementations
|
||||
# These contain proprietary multi-phase logic and should not be committed
|
||||
.agents/workflows/implement-features-ag.md
|
||||
.agents/workflows/port-upstream-features-ag.md
|
||||
.agents/workflows/port-upstream-issues-ag.md
|
||||
.agents/skills/implement-features/
|
||||
.claude/commands/implement-features-cc.md
|
||||
.claude/commands/port-upstream-features-cc.md
|
||||
.claude/commands/port-upstream-issues-cc.md
|
||||
.claude/worktrees/
|
||||
.codegraph/
|
||||
|
||||
# Fumadocs generated source
|
||||
.source/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
.claude/
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
|
||||
#hidden local data directories (never commit)
|
||||
.local-data/
|
||||
.data-dev/
|
||||
/.junie/
|
||||
|
||||
# internal setup prompts with personal credentials — never commit
|
||||
CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline é commitado, métricas não)
|
||||
quality-metrics.json
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260410 .txt
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
|
||||
omniroute.md
|
||||
|
||||
@@ -5,8 +5,20 @@ if ! command -v npx >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Cheap, deterministic local gates (re-enabled). Slower checks (i18n drift,
|
||||
# openapi coverage/security-tiers, env-doc sync) run in CI to keep commits fast.
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
|
||||
# Strict env-doc sync (FASE 2)
|
||||
node scripts/check/check-env-doc-sync.mjs
|
||||
|
||||
# CLI i18n consistency check — all t() keys must exist in en.json (FASE 8.3)
|
||||
node scripts/check/check-cli-i18n.mjs
|
||||
|
||||
# i18n docs drift advisory (FASE 5) — warn-only on pre-commit; CI enforces strict.
|
||||
node scripts/i18n/check-translation-drift.mjs --warn || \
|
||||
echo "⚠️ i18n drift detected. Run 'npm run i18n:run' to update locale mirrors."
|
||||
|
||||
# i18n UI coverage advisory (FASE 6) — pre-commit warns; CI enforces strict.
|
||||
node scripts/i18n/check-ui-keys-coverage.mjs --threshold=80 || \
|
||||
echo "⚠️ UI i18n coverage below 80% for at least one locale."
|
||||
|
||||
219
.i18n-state.json
Normal file
219
.i18n-state.json
Normal file
@@ -0,0 +1,219 @@
|
||||
{
|
||||
"sources": {
|
||||
"CLAUDE.md": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"locales": {
|
||||
"pt-BR": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "6ed62a4b8e85b48db9dbd3c5f00cec0a423742dd4ac518ff0206d6bf1732e714",
|
||||
"updated_at": "2026-05-18T22:01:38.515Z"
|
||||
},
|
||||
"az": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "b3da42c255bf9d70ac862ae083ad2c15193dfcd69407db70bb452bc03b735283",
|
||||
"updated_at": "2026-05-18T21:47:44.228Z"
|
||||
},
|
||||
"bn": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "cbb9eb8a552e7f6ceb0d45fdd115db2d88e90c420944f27fdfce8a461603c50a",
|
||||
"updated_at": "2026-05-18T21:48:17.508Z"
|
||||
},
|
||||
"ar": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "179543e6015946c2409a72ad87519c71a86086982fa573061a9d958675face59",
|
||||
"updated_at": "2026-05-18T21:48:47.676Z"
|
||||
},
|
||||
"cs": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "ee2d587bb968619c4791ee7fa75909f40aa1d6fb5e97c293f8ff2ac4bc22167c",
|
||||
"updated_at": "2026-05-18T21:51:04.860Z"
|
||||
},
|
||||
"da": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "4672b5643ab8f9adfcf8a14199c127dcc54380ed4cbaccd3d946f0db25fbead3",
|
||||
"updated_at": "2026-05-18T21:51:13.777Z"
|
||||
},
|
||||
"de": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "1a36bebb379ff38306b0e850d2460f4cd9c5bc70b8b1f7ef9c42b84c833e22cd",
|
||||
"updated_at": "2026-05-18T21:51:16.409Z"
|
||||
},
|
||||
"es": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "269c2b0842485060471ff2a7179fd16cc40273e7c8d27f28477d8724a1333b26",
|
||||
"updated_at": "2026-05-18T21:51:22.611Z"
|
||||
},
|
||||
"fa": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "8b4603ebc4875f4af2ca67c571282038f566dce5b62d17caa361c0ec879c2bea",
|
||||
"updated_at": "2026-05-18T21:52:38.812Z"
|
||||
},
|
||||
"fi": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "16a3a0b0df89e37808d7567e502944fb6792e14eb8c61e4b474b0fe79b310d63",
|
||||
"updated_at": "2026-05-18T21:52:44.086Z"
|
||||
},
|
||||
"fr": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "1a8bb2fdc17927047842bf6806787e9cea83d8f2912bc0291836574af877cde1",
|
||||
"updated_at": "2026-05-18T21:52:44.715Z"
|
||||
},
|
||||
"he": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "446414fae3f108ec2ac0fea09d826fab6a6dcfe2189e33f63d0068d7ffe945b2",
|
||||
"updated_at": "2026-05-18T21:54:06.000Z"
|
||||
},
|
||||
"hi": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "24ce173a1252a9849917324395ae5712e7d03d98ab9e9119a98c12c405e5fd96",
|
||||
"updated_at": "2026-05-18T21:54:16.102Z"
|
||||
},
|
||||
"gu": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "4d49e4be59a029c9407ca023a7f7ef21533e2ccf9c1b857a84eec0d5c1e0b1eb",
|
||||
"updated_at": "2026-05-18T21:55:11.836Z"
|
||||
},
|
||||
"hu": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "85881f57fc6a606e122466a368b159f3b7f6a8f0fa90aa7fe039f0127e436d44",
|
||||
"updated_at": "2026-05-18T21:55:54.603Z"
|
||||
},
|
||||
"id": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "e970c79fe15c9f78745b1b53e5eed3b8914641a0a3f81ce2b5207a21dd850d4e",
|
||||
"updated_at": "2026-05-18T21:56:05.289Z"
|
||||
},
|
||||
"in": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "9127b1ab7c33307a62ba12adc36ae0f70809b1ff21cd562b0d2f4c52ba5aae0d",
|
||||
"updated_at": "2026-05-18T21:56:08.126Z"
|
||||
},
|
||||
"it": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "949279fa3953e26345303fdb0defb959d722cfd8e0858a2fd1353384bdee2d66",
|
||||
"updated_at": "2026-05-18T21:56:31.825Z"
|
||||
},
|
||||
"ja": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "e2eec56e1fa368594e32e6184defc91bccf6b086f84de8a03774efa3b4b21cc7",
|
||||
"updated_at": "2026-05-18T21:57:34.374Z"
|
||||
},
|
||||
"mr": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "c795db0cde7b51a434a5bf06384fbec09073a2b7418fb0dddd90fa12f46fb7e1",
|
||||
"updated_at": "2026-05-18T21:58:25.858Z"
|
||||
},
|
||||
"ko": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "8d3f5cfda2ad3ce01e882cf2008de9f2106279e0f3a180cd8fa70280870b9761",
|
||||
"updated_at": "2026-05-18T21:58:25.863Z"
|
||||
},
|
||||
"ms": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "f2e13499c45cea20bcf178eac842ba923859bcc25822c6b4b9cf627512f3cce9",
|
||||
"updated_at": "2026-05-18T21:58:33.906Z"
|
||||
},
|
||||
"nl": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "81f851a5aff422692c62fd65c1289598bfc779f8ca02af7083215a0d0cf42575",
|
||||
"updated_at": "2026-05-18T21:59:16.560Z"
|
||||
},
|
||||
"phi": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "bf3fb36abd19f82bb296fe9fada413368b06b60d7931c352ebd1da8c3ff2c9fa",
|
||||
"updated_at": "2026-05-18T21:59:52.998Z"
|
||||
},
|
||||
"no": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "3263bf8e5287023b7e50f1a2e65c069b2a3719cbf58b1454e451b19ac56b2c93",
|
||||
"updated_at": "2026-05-18T22:00:47.607Z"
|
||||
},
|
||||
"pl": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "549129cf48c266b953b13e32efdbd8b7ba09848d68f76a984471c8f3b7c15338",
|
||||
"updated_at": "2026-05-18T22:01:03.762Z"
|
||||
},
|
||||
"ro": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "6391e37e4289f64b4e58a2922f42d0163f50dd836f55dbad1aa2d39020c27a68",
|
||||
"updated_at": "2026-05-18T22:02:54.102Z"
|
||||
},
|
||||
"ru": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "6aa38bc3c3c835bcd5ddf37d9a1ed29bf57d3234ee910f87c92b1363254ad4e6",
|
||||
"updated_at": "2026-05-18T22:02:57.045Z"
|
||||
},
|
||||
"pt": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "3a15114c2839e513ce1983571ab7ac312856a29f1284a49d80eba3860b06b3ed",
|
||||
"updated_at": "2026-05-18T22:03:11.851Z"
|
||||
},
|
||||
"sk": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "d7bf32867f17562967d0af5b78ae4c71abd4af3c7523128b041739dd9b02131b",
|
||||
"updated_at": "2026-05-18T22:04:00.222Z"
|
||||
},
|
||||
"sw": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "ae3337d4ef14ab15a25a60f2d74db80be8b2e82cdb3911f9a92b22fa5fd5395b",
|
||||
"updated_at": "2026-05-18T22:04:46.376Z"
|
||||
},
|
||||
"sv": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "36f1ca81330f13a3489523711dcf9b3a051518164345dfefc3097dbc1459e1b3",
|
||||
"updated_at": "2026-05-18T22:05:02.302Z"
|
||||
},
|
||||
"ta": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "12f7f379ca9970a561aa71a56f58734f6d454bf47f99bc240e52c2943aa585bb",
|
||||
"updated_at": "2026-05-18T22:05:29.419Z"
|
||||
},
|
||||
"te": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "7334cc88818d46644f138e8c5aee8045346cc6a9e98f597bd616ff8de02d17e9",
|
||||
"updated_at": "2026-05-18T22:06:17.933Z"
|
||||
},
|
||||
"tr": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "7284ba38f5a9b81f70926c923870f6ee1d29e9e34e7074a087e5ad9812794c91",
|
||||
"updated_at": "2026-05-18T22:07:15.730Z"
|
||||
},
|
||||
"th": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "b300d8668da2de9dd5e3c80b3a6d138b677662965be6c1459dcc145fbe88c062",
|
||||
"updated_at": "2026-05-18T22:07:44.472Z"
|
||||
},
|
||||
"ur": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "c2dfcb2125306a21b293a054f72d0946af4c9f29058fb679bcca3fbda7608a5a",
|
||||
"updated_at": "2026-05-18T22:08:34.169Z"
|
||||
},
|
||||
"vi": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "ece90f91f228a0c504e4537d55e951b48763cafc14531a81dfc42d8a1ad45771",
|
||||
"updated_at": "2026-05-18T22:09:55.405Z"
|
||||
},
|
||||
"zh-CN": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "780e02b27f556c748090474ad9081d8b3fd92018b14329407ac54a8f7e27688b",
|
||||
"updated_at": "2026-05-18T22:10:23.922Z"
|
||||
},
|
||||
"uk-UA": {
|
||||
"source_hash": "951d6ba25c4ced12e53f5dead589013135b827a1ed908a6dc5c5e5dcc3cc96cb",
|
||||
"target_hash": "9a7a645713fb8b23de2a1a9b24317376a7a83fee994830bb81a2889e34efc0ee",
|
||||
"updated_at": "2026-05-18T22:18:32.917Z"
|
||||
}
|
||||
}
|
||||
},
|
||||
"docs/architecture/ARCHITECTURE.md": {
|
||||
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
|
||||
"locales": {
|
||||
"pt-BR": {
|
||||
"source_hash": "a065bbff5e461cca6cd1a81d2923626040548c3b3af98ecb2a69a99709bd8059",
|
||||
"target_hash": "7ee10dc8cba2668fd279af64ecca3a1760e974b6dc9672aee81e69c63ee41175",
|
||||
"updated_at": "2026-05-20T05:34:41.826Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
.npmignore
21
.npmignore
@@ -9,16 +9,6 @@ app/vscode-extension/
|
||||
**/db.json
|
||||
|
||||
# Source code (pre-built app/ is published instead)
|
||||
#
|
||||
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
|
||||
# ships. It now allowlists the backend source closure the MCP server needs at runtime
|
||||
# (open-sse/, src/lib, src/server, ...) and OVERRIDES the broad src/ + open-sse/ excludes
|
||||
# below — npm honors files[] over .npmignore for inclusion. These lines are kept only as
|
||||
# intent/back-stop: if files[] is ever trimmed back to specific paths, they must NOT be
|
||||
# allowed to re-hide the MCP closure (that would silently reintroduce the --mcp
|
||||
# ERR_MODULE_NOT_FOUND #3578 fixed). The closure gate in
|
||||
# tests/unit/mcp-published-files-closure-3578.test.ts asserts the real `npm pack` output
|
||||
# in both directions (closure present + zero test files), catching such a regression.
|
||||
src/
|
||||
open-sse/
|
||||
docs/
|
||||
@@ -28,17 +18,6 @@ images/
|
||||
logs/
|
||||
scripts/
|
||||
|
||||
# Co-located tests must never ship even when their parent dir is allowlisted by files[].
|
||||
# (Primary guard is the "!**/*.test.*" negations in package.json files[]; this is defense
|
||||
# in depth for any nested dir the allowlist pulls in.)
|
||||
**/__tests__/
|
||||
**/*.test.ts
|
||||
**/*.test.tsx
|
||||
**/*.test.js
|
||||
**/*.test.mjs
|
||||
**/*.spec.ts
|
||||
**/*.spec.tsx
|
||||
|
||||
# Config/dev files
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
31
.semgrep/rules/cli-no-sqlite.yaml
Normal file
31
.semgrep/rules/cli-no-sqlite.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
rules:
|
||||
- id: cli-no-sqlite-direct
|
||||
patterns:
|
||||
- pattern: new Database(...)
|
||||
paths:
|
||||
include:
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or
|
||||
withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and
|
||||
bin/cli/CONVENTIONS.md.
|
||||
languages: [js]
|
||||
severity: ERROR
|
||||
|
||||
- id: cli-no-raw-sql
|
||||
patterns:
|
||||
- pattern: $DB.prepare("INSERT INTO ...")
|
||||
- pattern: $DB.prepare("DELETE FROM ...")
|
||||
- pattern: $DB.prepare("UPDATE $TABLE SET ...")
|
||||
paths:
|
||||
include:
|
||||
- "/bin/**"
|
||||
exclude:
|
||||
- "/bin/cli/sqlite.mjs"
|
||||
message: >
|
||||
Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md
|
||||
hard rule #5 and bin/cli/CONVENTIONS.md.
|
||||
languages: [js]
|
||||
severity: ERROR
|
||||
@@ -1,8 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
|
||||
import * as Config from '../source.config';
|
||||
|
||||
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
|
||||
DocData: {
|
||||
}
|
||||
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});
|
||||
@@ -1,23 +0,0 @@
|
||||
// source.config.ts
|
||||
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
|
||||
var docs = defineDocs({
|
||||
dir: "docs",
|
||||
docs: {
|
||||
files: [
|
||||
"./getting-started/**/*.md",
|
||||
"./architecture/**/*.md",
|
||||
"./guides/**/*.md",
|
||||
"./reference/**/*.md",
|
||||
"./frameworks/**/*.md",
|
||||
"./routing/**/*.md",
|
||||
"./security/**/*.md",
|
||||
"./compression/**/*.md",
|
||||
"./ops/**/*.md"
|
||||
]
|
||||
}
|
||||
});
|
||||
var source_config_default = defineConfig();
|
||||
export {
|
||||
source_config_default as default,
|
||||
docs
|
||||
};
|
||||
31
.vscode/launch.json
vendored
Normal file
31
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Dev Server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${env:HOME}/.nvm/versions/node/v22.22.2/bin/node",
|
||||
"program": "${workspaceFolder}/scripts/run-next.mjs",
|
||||
"args": ["dev"],
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": ["<node_internals>/**", "node_modules/**"]
|
||||
},
|
||||
{
|
||||
"name": "Debug Prod Server",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"program": "${workspaceFolder}/scripts/run-next.mjs",
|
||||
"args": ["start"],
|
||||
"console": "integratedTerminal",
|
||||
"skipFiles": ["<node_internals>/**", "node_modules/**"]
|
||||
},
|
||||
{
|
||||
"name": "Attach to Running Server",
|
||||
"type": "node",
|
||||
"request": "attach",
|
||||
"port": 9229,
|
||||
"skipFiles": ["<node_internals>/**", "node_modules/**"]
|
||||
}
|
||||
]
|
||||
}
|
||||
24
.vscode/settings.json
vendored
24
.vscode/settings.json
vendored
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"workbench.sideBar.location": "left",
|
||||
"css.lint.unknownAtRules": "ignore",
|
||||
"sonarlint.rules": {
|
||||
"css:S4662": {
|
||||
@@ -36,18 +35,7 @@
|
||||
"**/node_modules/**": true,
|
||||
"**/.next/**": true,
|
||||
"**/coverage/**": true,
|
||||
"**/_tasks/**": true,
|
||||
"**/.git/objects/**": true,
|
||||
"**/dist/**": true,
|
||||
"**/build/**": true,
|
||||
"**/out/**": true,
|
||||
"**/.cache/**": true,
|
||||
"**/.turbo/**": true,
|
||||
"**/OmniRoute-*/**": true,
|
||||
"**/*-merge-*/**": true,
|
||||
"**/*-worktree-*/**": true,
|
||||
"**/*-issues-*/**": true,
|
||||
"**/*-reorg*/**": true
|
||||
"**/_tasks/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/_references": true,
|
||||
@@ -56,14 +44,6 @@
|
||||
"**/node_modules": true,
|
||||
"**/.next": true,
|
||||
"**/coverage": true,
|
||||
"**/_tasks": true,
|
||||
"**/dist": true,
|
||||
"**/build": true,
|
||||
"**/out": true,
|
||||
"**/OmniRoute-*": true,
|
||||
"**/*-merge-*": true,
|
||||
"**/*-worktree-*": true,
|
||||
"**/*-issues-*": true,
|
||||
"**/*-reorg*": true
|
||||
"**/_tasks": true
|
||||
}
|
||||
}
|
||||
|
||||
4
@omniroute/opencode-plugin/.gitignore
vendored
4
@omniroute/opencode-plugin/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 OmniRoute contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,269 +0,0 @@
|
||||
# @omniroute/opencode-plugin
|
||||
|
||||
> **Recommended way to use OmniRoute with OpenCode.** Pulls a live model catalog from `/v1/models` (including `-low`/`-medium`/`-high`/`-thinking` variants as first-class IDs), aggregates combos via `/api/combos` using a least-common-denominator capability/limit join, sanitizes Gemini tool schemas in flight, and supports multiple side-by-side OmniRoute instances out of the box.
|
||||
|
||||
## Why this and not `@omniroute/opencode-provider`?
|
||||
|
||||
`@omniroute/opencode-provider` is the legacy config-generator package — it writes a frozen `provider.omniroute` block into `opencode.json` with a **hardcoded list of 8 models** ([`OMNIROUTE_DEFAULT_OPENCODE_MODELS`](https://github.com/diegosouzapw/OmniRoute/blob/main/%40omniroute/opencode-provider/src/index.ts#L48-L56)). It works on the CLI but in the **OpenCode Desktop / Web** builds (Tauri / Electron) the runtime re-runs the model picker and the static block surfaces only a few of those — and they drift behind the live OmniRoute catalog.
|
||||
|
||||
This plugin solves that by:
|
||||
|
||||
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
|
||||
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
|
||||
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
|
||||
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
|
||||
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
|
||||
|
||||
**If you only have the legacy `opencode-provider` block in your `opencode.json`, replace it with a single plugin entry.** No other config changes required — the same `auth.json` API key works.
|
||||
|
||||
## Install
|
||||
|
||||
Once published to npm:
|
||||
|
||||
```sh
|
||||
npm install @omniroute/opencode-plugin
|
||||
```
|
||||
|
||||
Until then (or for local development), reference the built artifact directly. Either extract the package into your OpenCode plugins dir and point at the extracted `dist/index.js`:
|
||||
|
||||
```sh
|
||||
# from inside the OmniRoute repo
|
||||
cd @omniroute/opencode-plugin && npm run build && npm pack
|
||||
# then extract into ~/.config/opencode/plugins/omniroute-opencode-plugin/
|
||||
```
|
||||
|
||||
Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
|
||||
## Quick start (single instance)
|
||||
|
||||
```jsonc
|
||||
// opencode.json
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
|
||||
```
|
||||
|
||||
> ⚠ Use the `--provider` flag explicitly. `opencode auth login omniroute` is parsed as a positional `url` argument by current OC releases (≤1.15.5) and fails with `fetch() URL is invalid`. Tracked upstream.
|
||||
|
||||
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
|
||||
|
||||
## Multi-instance (prod + preprod side-by-side)
|
||||
|
||||
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
|
||||
|
||||
### Dual-install workaround (works today on OC ≤1.15.5)
|
||||
|
||||
Pack the plugin once, extract it twice into named directories, then point each `plugin:` entry at its own copy:
|
||||
|
||||
```sh
|
||||
# 1. Build + pack the plugin (run from the plugin worktree)
|
||||
cd /path/to/OmniRoute/@omniroute/opencode-plugin
|
||||
npm run build
|
||||
npm pack
|
||||
# produces omniroute-opencode-plugin-0.1.0.tgz
|
||||
|
||||
# 2. Extract one copy per OmniRoute endpoint
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-prod --strip-components=1
|
||||
tar -xzf omniroute-opencode-plugin-0.1.0.tgz -C ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod --strip-components=1
|
||||
```
|
||||
|
||||
Then in `~/.config/opencode/opencode.json` reference each directory by absolute path:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"plugin": [
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-prod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"displayName": "OmniRoute",
|
||||
"baseURL": "https://or.example.com",
|
||||
},
|
||||
],
|
||||
[
|
||||
"./plugins/omniroute-opencode-plugin-preprod/dist/index.js",
|
||||
{
|
||||
"providerId": "omniroute-preprod",
|
||||
"displayName": "OmniRoute Preprod",
|
||||
"baseURL": "https://or-preprod.example.com",
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
|
||||
|
||||
```sh
|
||||
opencode auth login --provider omniroute
|
||||
opencode auth login --provider omniroute-preprod
|
||||
```
|
||||
|
||||
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
|
||||
|
||||
### After publish (`@omniroute/opencode-plugin` npm)
|
||||
|
||||
Once the package is published, the dual-install becomes two `npm install --prefix` commands instead of `tar -xzf`:
|
||||
|
||||
```sh
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-prod
|
||||
mkdir -p ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prod @omniroute/opencode-plugin
|
||||
npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-preprod @omniroute/opencode-plugin
|
||||
```
|
||||
|
||||
`opencode.json` paths become `./plugins/omniroute-opencode-plugin-prod/node_modules/@omniroute/opencode-plugin/dist/index.js` (and the preprod equivalent).
|
||||
|
||||
## Features
|
||||
|
||||
| Feature | What it does | Hook |
|
||||
| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
|
||||
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
|
||||
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
|
||||
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
|
||||
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
|
||||
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
|
||||
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
|
||||
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
|
||||
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
|
||||
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
|
||||
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
|
||||
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
|
||||
| Multi-instance | Each plugin entry binds to its own `providerId`; closures isolated | factory |
|
||||
| Config-hook shim | OC ≤1.15.5 fallback: writes static catalog into `config.provider[id]` (config hook is the only one that fires in `serve` mode on these versions) | `config` |
|
||||
|
||||
## Plugin options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
|
||||
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
|
||||
|
||||
### `features` block
|
||||
|
||||
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
|
||||
|
||||
| Feature | Type | Default | What it does |
|
||||
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
|
||||
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached` → `cacheRead`, `cache_creation` → `cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
|
||||
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
|
||||
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models` → `GHM`, `Gemini-cli` → `GEMINI-CLI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
|
||||
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
|
||||
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
|
||||
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
|
||||
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
|
||||
|
||||
#### Example — enrichment + compression tags + MCP auto-emit
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
"compressionMetadata": true,
|
||||
"mcpAutoEmit": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
With `mcpAutoEmit: true`, the plugin synthesises an `mcp.omniroute` entry equivalent to a manual:
|
||||
|
||||
```jsonc
|
||||
"mcp": {
|
||||
"omniroute": {
|
||||
"type": "remote",
|
||||
"url": "https://or.example.com/api/mcp/stream",
|
||||
"enabled": true,
|
||||
"headers": { "Authorization": "Bearer <apiKey-from-auth.json>" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If you want a narrower-scoped Bearer for MCP (different from the chat/inference key), set `features.mcpToken`. Operator overrides win: if you already set `mcp.omniroute` in `opencode.json`, the plugin will not overwrite it.
|
||||
|
||||
#### Example — production-leaning defaults (clean picker, offline resilience)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugin": [
|
||||
[
|
||||
"@omniroute/opencode-plugin",
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"features": {
|
||||
"combos": true,
|
||||
"enrichment": true,
|
||||
"compressionMetadata": true,
|
||||
"usableOnly": true,
|
||||
"diskCache": true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
- `usableOnly: true` drops models whose canonical provider has no healthy connection in your OmniRoute instance — your `/models` picker stays focused on what you can actually call.
|
||||
- `diskCache: true` (default) writes a snapshot to `${OPENCODE_DATA_DIR}/plugins/omniroute-<providerId>.json` on every healthy refresh. On a cold start where `/v1/models` is unreachable (laptop offline, IP whitelist drop), the snapshot hydrates the static block so OC still shows the catalog instead of a stub.
|
||||
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
|
||||
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
|
||||
|
||||
## Comparison vs `@omniroute/opencode-provider`
|
||||
|
||||
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.
|
||||
|
||||
| | `@omniroute/opencode-plugin` (this) | `@omniroute/opencode-provider` |
|
||||
| ----------------- | ----------------------------------- | --------------------------------- |
|
||||
| Type | OC plugin | Config generator (CLI/build-time) |
|
||||
| Models | Live from `/v1/models` | Frozen at scaffold |
|
||||
| Combos | LCD-aggregated live | None |
|
||||
| Gemini sanitize | Yes | N/A |
|
||||
| OC UI integration | `/connect`, `/models` | None |
|
||||
| Multi-instance | Native | Manual |
|
||||
|
||||
Both can coexist; pick the one that fits your environment.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node `>=22.22.3` (per `engines.node`); tested on Node 22 and 24.
|
||||
- OpenCode: verified end-to-end against `opencode@1.15.5` with `@opencode-ai/plugin@1.15.6`.
|
||||
- OC plugin peer (`@opencode-ai/plugin`) `>=1.14.49` for the full feature set (provider hook surfaces models in `/models`). On `<=1.14.48`, the plugin falls back to its `config` hook, writing a static catalog snapshot into `config.provider[id]` so models still appear.
|
||||
- The plugin uses the OC v1 plugin shape (`default: { id, server }`) — older OC releases that only walk named exports will reject it. Stay on OC ≥1.15.
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](./LICENSE).
|
||||
2414
@omniroute/opencode-plugin/package-lock.json
generated
2414
@omniroute/opencode-plugin/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
"omniroute",
|
||||
"opencode",
|
||||
"opencode-plugin",
|
||||
"ai-sdk",
|
||||
"openai-compatible",
|
||||
"provider",
|
||||
"gemini",
|
||||
"combos",
|
||||
"mcp"
|
||||
],
|
||||
"author": "OmniRoute contributors",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute.git",
|
||||
"directory": "@omniroute/opencode-plugin"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/diegosouzapw/OmniRoute/issues"
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-plugin#readme",
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode-ai/plugin": "*"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.15.6",
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Structured logger for the OmniRoute plugin.
|
||||
*
|
||||
* Levels: error < warn < info < debug
|
||||
* Default: warn (matches current console.warn behavior)
|
||||
* Set via features.logLevel in plugin options.
|
||||
*/
|
||||
|
||||
export type LogLevel = "error" | "warn" | "info" | "debug";
|
||||
|
||||
const LEVEL_ORDER: Record<LogLevel, number> = {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
debug: 3,
|
||||
};
|
||||
|
||||
const TAG = "[omniroute-plugin]";
|
||||
|
||||
function shouldLog(current: LogLevel, target: LogLevel): boolean {
|
||||
return LEVEL_ORDER[current] >= LEVEL_ORDER[target];
|
||||
}
|
||||
|
||||
let _level: LogLevel = "warn";
|
||||
|
||||
export function setLogLevel(level: LogLevel): void {
|
||||
_level = level;
|
||||
}
|
||||
|
||||
export function getLogLevel(): LogLevel {
|
||||
return _level;
|
||||
}
|
||||
|
||||
function fmt(level: LogLevel, msg: string, tag?: string): string {
|
||||
const prefix = tag ? `${TAG}${tag}` : TAG;
|
||||
return `${prefix} [${level.toUpperCase()}] ${msg}`;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
error(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
|
||||
},
|
||||
warn(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
|
||||
},
|
||||
info(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
|
||||
},
|
||||
debug(msg: string, ...args: unknown[]): void {
|
||||
if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
|
||||
},
|
||||
/** Always emit regardless of level (for critical init breadcrumbs). */
|
||||
always(msg: string, ...args: unknown[]): void {
|
||||
console.warn(TAG, msg, ...args);
|
||||
},
|
||||
|
||||
// ── Tagged child loggers ──────────────────────────────────────────────
|
||||
child(tag: string) {
|
||||
return {
|
||||
error: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "error") &&
|
||||
console.error(fmt("error", msg, tag), ...args),
|
||||
warn: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "warn") &&
|
||||
console.warn(fmt("warn", msg, tag), ...args),
|
||||
info: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "info") &&
|
||||
console.warn(fmt("info", msg, tag), ...args),
|
||||
debug: (msg: string, ...args: unknown[]) =>
|
||||
shouldLog(_level, "debug") &&
|
||||
console.warn(fmt("debug", msg, tag), ...args),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,301 +0,0 @@
|
||||
/**
|
||||
* Universal model naming template for the OmniRoute plugin.
|
||||
*
|
||||
* Naming pipeline:
|
||||
* [tag] <provider-label><separator><display-name><suffix>
|
||||
*
|
||||
* [Free] <provider> - <name> · <budget> ← free model
|
||||
* Auto: <variant> (<N>p) ← auto combo
|
||||
* Combo: <name> ← DB combo
|
||||
* <provider> - <name> ← regular model
|
||||
*/
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Separator between provider label and model display name. */
|
||||
export const PROVIDER_TAG_SEPARATOR = " - ";
|
||||
|
||||
/** Threshold beyond which providerDisplayName is abbreviated. */
|
||||
const PROVIDER_LABEL_MAX_CHARS = 12;
|
||||
|
||||
/** Aliases longer than this get title-case instead of UPPER. */
|
||||
const ALIAS_UPPER_MAX_CHARS = 5;
|
||||
|
||||
// ── Auto Combo Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type AutoVariant =
|
||||
| "coding"
|
||||
| "fast"
|
||||
| "cheap"
|
||||
| "offline"
|
||||
| "smart"
|
||||
| "lkgp";
|
||||
|
||||
export const AUTO_VARIANTS: AutoVariant[] = [
|
||||
"coding",
|
||||
"fast",
|
||||
"cheap",
|
||||
"offline",
|
||||
"smart",
|
||||
"lkgp",
|
||||
];
|
||||
|
||||
export const AUTO_VARIANT_DESCRIPTIONS: Record<
|
||||
AutoVariant | "default",
|
||||
string
|
||||
> = {
|
||||
default: "Best provider via scoring",
|
||||
coding: "Quality-first for code tasks",
|
||||
fast: "Latency-optimized routing",
|
||||
cheap: "Cost-optimized routing",
|
||||
offline: "Offline-friendly providers",
|
||||
smart: "Quality-first with exploration",
|
||||
lkgp: "Last-Known-Good-Provider routing",
|
||||
};
|
||||
|
||||
// ── Free Model Types ─────────────────────────────────────────────────────
|
||||
|
||||
export type FreeModelFreeType =
|
||||
| "recurring-daily"
|
||||
| "recurring-monthly"
|
||||
| "recurring-credit"
|
||||
| "one-time-initial"
|
||||
| "keyless"
|
||||
| "discontinued";
|
||||
|
||||
// ── Provider Label ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Title-case a long, lowercase-looking alias.
|
||||
* `antigravity` → `Antigravity`
|
||||
*/
|
||||
function titleCaseAlias(alias: string): string {
|
||||
if (alias.length === 0) return alias;
|
||||
return alias.charAt(0).toUpperCase() + alias.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the short label for an upstream provider.
|
||||
*
|
||||
* Rules:
|
||||
* 1. Trim `providerDisplayName`. If ≤12 chars → use verbatim.
|
||||
* 2. Alias ≤5 chars → UPPER(alias). Alias >5 → titleCase.
|
||||
* 3. Neither → undefined.
|
||||
*/
|
||||
export function shortProviderLabel(
|
||||
enrichment:
|
||||
| { providerDisplayName?: string; providerAlias?: string }
|
||||
| undefined,
|
||||
): string | undefined {
|
||||
if (!enrichment) return undefined;
|
||||
const raw =
|
||||
typeof enrichment.providerDisplayName === "string"
|
||||
? enrichment.providerDisplayName.trim()
|
||||
: "";
|
||||
if (raw.length > 0 && raw.length <= PROVIDER_LABEL_MAX_CHARS) return raw;
|
||||
const alias =
|
||||
typeof enrichment.providerAlias === "string"
|
||||
? enrichment.providerAlias.trim()
|
||||
: "";
|
||||
if (alias.length > 0) {
|
||||
return alias.length <= ALIAS_UPPER_MAX_CHARS
|
||||
? alias.toUpperCase()
|
||||
: titleCaseAlias(alias);
|
||||
}
|
||||
// Long displayName with no alias to fall back on: keep the long label
|
||||
// rather than dropping the provider prefix entirely.
|
||||
return raw.length > 0 ? raw : undefined;
|
||||
}
|
||||
|
||||
// ── Free Label ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Normalise display name so free-tier models get a consistent `[Free] ` prefix.
|
||||
*
|
||||
* "GPT-4.1 (Free)" → "[Free] GPT-4.1"
|
||||
* "DeepSeek V4 Flash Free" → "[Free] DeepSeek V4 Flash"
|
||||
* "Claude Opus 4.7" → "Claude Opus 4.7" (unchanged)
|
||||
*/
|
||||
export function normaliseFreeLabel(name: string): string {
|
||||
// Bounded whitespace quantifiers ({0,8}/{1,8}) avoid the polynomial-ReDoS
|
||||
// backtracking that unbounded \s* before an anchored \s*$ would allow on
|
||||
// attacker-influenced display names. 8 covers any realistic label spacing.
|
||||
const cleaned = name
|
||||
.replace(/\s{0,8}\(free\)\s{0,8}$/i, "")
|
||||
.replace(/[\s-]{1,8}free\s{0,8}$/i, "")
|
||||
.trim();
|
||||
const wasFree = cleaned.length < name.trim().length;
|
||||
if (!wasFree) return name;
|
||||
return `[Free] ${cleaned}`;
|
||||
}
|
||||
|
||||
// ── Free Budget Formatting ────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1).replace(/\.0$/, "") + "B";
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a free model budget into a short human-readable suffix.
|
||||
*
|
||||
* recurring-daily → "25M tokens/day"
|
||||
* recurring-monthly → "25M tokens/month"
|
||||
* recurring-credit → "10M credits"
|
||||
* one-time-initial → "1M credits (one-time)"
|
||||
* keyless → "(keyless)"
|
||||
* discontinued → "(discontinued)"
|
||||
*/
|
||||
export function formatFreeBudget(params: {
|
||||
freeType: FreeModelFreeType;
|
||||
monthlyTokens?: number;
|
||||
creditTokens?: number;
|
||||
}): string {
|
||||
const { freeType, monthlyTokens = 0, creditTokens = 0 } = params;
|
||||
|
||||
switch (freeType) {
|
||||
case "recurring-daily":
|
||||
return `${fmtTokens(monthlyTokens)} tokens/day`;
|
||||
case "recurring-monthly":
|
||||
return `${fmtTokens(monthlyTokens)} tokens/month`;
|
||||
case "recurring-credit":
|
||||
return `${fmtTokens(creditTokens)} credits`;
|
||||
case "one-time-initial":
|
||||
return `${fmtTokens(creditTokens)} credits (one-time)`;
|
||||
case "keyless":
|
||||
return "(keyless)";
|
||||
case "discontinued":
|
||||
return "(discontinued)";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto Combo Naming ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Format auto combo display name.
|
||||
*
|
||||
* "Auto: Coding (4p)"
|
||||
* "Auto: Default (6p)"
|
||||
* "Auto" (no candidate count when unknown)
|
||||
*/
|
||||
export function formatAutoComboName(
|
||||
variant: AutoVariant | undefined,
|
||||
candidateCount?: number,
|
||||
): string {
|
||||
const label = variant
|
||||
? variant.charAt(0).toUpperCase() + variant.slice(1)
|
||||
: "Default";
|
||||
const count =
|
||||
typeof candidateCount === "number" && candidateCount > 0
|
||||
? ` (${candidateCount}p)`
|
||||
: "";
|
||||
return `Auto: ${label}${count}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the model ID for an auto combo entry.
|
||||
* "auto/coding", "auto/fast", "auto" (default).
|
||||
*/
|
||||
export function autoComboModelId(variant: AutoVariant | undefined): string {
|
||||
return variant ? `auto/${variant}` : "auto";
|
||||
}
|
||||
|
||||
// ── Universal Display Name Builder ────────────────────────────────────────
|
||||
|
||||
export interface ModelDisplayNameParams {
|
||||
/** Raw model ID (e.g. "cc/claude-sonnet-4-6"). */
|
||||
rawId: string;
|
||||
/** Enrichment display name (e.g. "Claude Sonnet 4.6"). */
|
||||
enrichmentName?: string;
|
||||
/** Provider tag enrichment. */
|
||||
providerAlias?: string;
|
||||
/** Human-readable upstream provider label. */
|
||||
providerDisplayName?: string;
|
||||
/** Whether model is free tier. */
|
||||
isFree?: boolean;
|
||||
/** Free model budget info. */
|
||||
freeType?: FreeModelFreeType;
|
||||
/** Monthly token budget (for recurring free models). */
|
||||
monthlyTokens?: number;
|
||||
/** Credit token budget (for credit-based free models). */
|
||||
creditTokens?: number;
|
||||
/** Whether this is a combo entry (skip provider tag). */
|
||||
isCombo?: boolean;
|
||||
/** Whether this is an auto combo entry. */
|
||||
isAutoCombo?: boolean;
|
||||
/** Auto combo variant. */
|
||||
autoVariant?: AutoVariant;
|
||||
/** Auto combo candidate count. */
|
||||
autoCandidateCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final display name following the universal template.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Auto combo → "Auto: <variant> (<N>p)"
|
||||
* 2. DB combo → "Combo: <name>"
|
||||
* 3. Free + enrichment + provider tag → "[Free] <label> - <name> · <budget>"
|
||||
* 4. Free + enrichment → "[Free] <name> · <budget>"
|
||||
* 5. Free + raw → "[Free] <rawId> · <budget>"
|
||||
* 6. Enrichment + provider tag → "<label> - <name>"
|
||||
* 7. Enrichment only → "<name>"
|
||||
* 8. Raw fallback → normaliseFreeLabel(rawId)
|
||||
*/
|
||||
export function buildModelDisplayName(params: ModelDisplayNameParams): string {
|
||||
// Auto combos
|
||||
if (params.isAutoCombo) {
|
||||
return formatAutoComboName(params.autoVariant, params.autoCandidateCount);
|
||||
}
|
||||
|
||||
// Determine base name — strip any existing free suffix first
|
||||
const rawBase =
|
||||
params.enrichmentName && params.enrichmentName.trim().length > 0
|
||||
? params.enrichmentName
|
||||
: params.rawId;
|
||||
const cleanedBase = rawBase
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/[\s-]+free\s*$/i, "")
|
||||
.trim();
|
||||
const wasFree = cleanedBase.length < rawBase.trim().length;
|
||||
const isFree = !!params.isFree || wasFree;
|
||||
|
||||
let baseName = cleanedBase;
|
||||
|
||||
// Provider tag (skip for combos)
|
||||
if (!params.isCombo) {
|
||||
const label = shortProviderLabel({
|
||||
providerDisplayName: params.providerDisplayName,
|
||||
providerAlias: params.providerAlias,
|
||||
});
|
||||
if (label) {
|
||||
const prefix = `${label}${PROVIDER_TAG_SEPARATOR}`;
|
||||
if (!baseName.startsWith(prefix)) {
|
||||
baseName = `${prefix}${baseName}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend [Free] if applicable (AFTER provider tag for correct ordering)
|
||||
if (isFree) {
|
||||
baseName = `[Free] ${baseName}`;
|
||||
}
|
||||
|
||||
// Free budget suffix
|
||||
if (isFree && params.freeType) {
|
||||
const budget = formatFreeBudget({
|
||||
freeType: params.freeType,
|
||||
monthlyTokens: params.monthlyTokens,
|
||||
creditTokens: params.creditTokens,
|
||||
});
|
||||
if (budget) {
|
||||
baseName = `${baseName} · ${budget}`;
|
||||
}
|
||||
}
|
||||
|
||||
return baseName;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* T-02 auth-hook contract tests.
|
||||
*
|
||||
* Covers the `createOmniRouteAuthHook(opts)` factory and its loader behaviour
|
||||
* against every Auth flavor (`api`, `oauth`, null, empty key). Validates the
|
||||
* multi-instance fix: provider id flows from plugin options, not a module
|
||||
* constant.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createOmniRouteAuthHook } from "../src/index.js";
|
||||
|
||||
test("createOmniRouteAuthHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(hook.provider, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: custom providerId binds to hook.provider (multi-instance)", () => {
|
||||
const hook = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(hook.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: methods[0] is type 'api' with label including displayName", () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.equal(Array.isArray(hook.methods), true);
|
||||
assert.equal(hook.methods.length, 1);
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
assert.equal(m.label, "OmniRoute API Key");
|
||||
|
||||
const custom = createOmniRouteAuthHook({ providerId: "omniroute-preprod" });
|
||||
assert.equal(custom.methods[0].label, "OmniRoute (omniroute-preprod) API Key");
|
||||
});
|
||||
|
||||
test("createOmniRouteAuthHook: prompts[0] uses key='apiKey' per @opencode-ai/plugin contract", () => {
|
||||
// NOTE: spec referenced `name: "apiKey"`; the official
|
||||
// @opencode-ai/plugin@1.15.6 prompt shape uses `key` + `message` (no
|
||||
// `name`/`label`/`mask` fields). Asserting against the real type contract.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const m = hook.methods[0];
|
||||
assert.equal(m.type, "api");
|
||||
// narrow: api method may carry prompts
|
||||
const prompts = "prompts" in m ? m.prompts : undefined;
|
||||
assert.ok(Array.isArray(prompts) && prompts.length === 1, "expected one prompt");
|
||||
const p = prompts![0];
|
||||
assert.equal(p.type, "text");
|
||||
assert.equal((p as { key: string }).key, "apiKey");
|
||||
assert.ok(
|
||||
typeof (p as { message: string }).message === "string" &&
|
||||
(p as { message: string }).message.includes("omniroute"),
|
||||
"prompt message should mention provider id"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey} when no baseURL option (T-04: fetch omitted)", async () => {
|
||||
// T-04 changed the loader return shape: without a resolvable baseURL the
|
||||
// interceptor cannot gate-keep requests, so the loader falls back to
|
||||
// apiKey-only and the AI-SDK uses its default fetch. See fetch-interceptor
|
||||
// tests for the wired-fetch branches.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
assert.ok(hook.loader, "loader must be defined");
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-test" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, { apiKey: "sk-test" });
|
||||
});
|
||||
|
||||
test("loader: valid api auth → {apiKey, baseURL, fetch} when baseURL option set (T-04)", async () => {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: "https://or.example.com/v1" });
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal((result as { apiKey: string }).apiKey, "sk-x");
|
||||
assert.equal((result as { baseURL: string }).baseURL, "https://or.example.com/v1");
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"T-04: loader must wire fetch interceptor when baseURL resolves"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: features.fetchInterceptor=false AND geminiSanitization=false → no custom fetch (flags honored)", async () => {
|
||||
// Regression: both fetch-layer flags were documented + schema-validated but
|
||||
// silently ignored. Disabling both must fall back to the SDK default fetch.
|
||||
const hook = createOmniRouteAuthHook({
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { fetchInterceptor: false, geminiSanitization: false },
|
||||
});
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, { apiKey: "sk-x", baseURL: "https://or.example.com/v1" });
|
||||
assert.equal(
|
||||
(result as { fetch?: unknown }).fetch,
|
||||
undefined,
|
||||
"both flags off must omit the custom fetch"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: features.fetchInterceptor=false but geminiSanitization=true → fetch still wired (sanitizer only)", async () => {
|
||||
const hook = createOmniRouteAuthHook({
|
||||
baseURL: "https://or.example.com/v1",
|
||||
features: { fetchInterceptor: false, geminiSanitization: true },
|
||||
});
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: "sk-x" }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"geminiSanitization alone must still provide a fetch wrapper"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: null/undefined auth → {} (no creds yet, OC surfaces /connect)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const r1 = await hook.loader!(async () => null as never, {} as never);
|
||||
assert.deepEqual(r1, {});
|
||||
const r2 = await hook.loader!(async () => undefined as never, {} as never);
|
||||
assert.deepEqual(r2, {});
|
||||
});
|
||||
|
||||
test("loader: oauth-flavored auth → {} (wrong method type, ignored)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(
|
||||
async () =>
|
||||
({
|
||||
type: "oauth",
|
||||
refresh: "r",
|
||||
access: "a",
|
||||
expires: 0,
|
||||
}) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
|
||||
test("loader: api auth with empty key → {} (empty creds rejected)", async () => {
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: "" }) as never, {} as never);
|
||||
assert.deepEqual(result, {});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* TDD regression — auto combos must never advertise `limit.context: 0`.
|
||||
*
|
||||
* opencode's overflow guard (packages/opencode/src/session/overflow.ts)
|
||||
* short-circuits when `model.limit.context === 0`:
|
||||
*
|
||||
* if (input.model.limit.context === 0) return false // never overflow
|
||||
*
|
||||
* so a zero context silently DISABLES opencode's smart auto-compaction for
|
||||
* auto combos. The session then grows unbounded until OmniRoute's
|
||||
* server-side purifyHistory() destructively drops old messages — the
|
||||
* "coding agent keeps forgetting things" bug.
|
||||
*
|
||||
* Fix under test: mapAutoComboToStaticEntry consumes the context_length /
|
||||
* max_output_tokens now served by GET /api/combos/auto, and falls back to a
|
||||
* safe positive default (128000 / 8192) for older servers that do not send
|
||||
* the fields yet.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { mapAutoComboToStaticEntry } from "../src/index.ts";
|
||||
import type { OmniRouteRawAutoCombo } from "../src/index.ts";
|
||||
|
||||
test("uses server-provided context_length and max_output_tokens", () => {
|
||||
const raw = {
|
||||
id: "auto/coding",
|
||||
name: "Auto Coding",
|
||||
variant: "coding",
|
||||
candidateCount: 5,
|
||||
context_length: 1048576,
|
||||
max_output_tokens: 65536,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.equal(entry.limit?.context, 1048576);
|
||||
assert.equal(entry.limit?.output, 65536);
|
||||
});
|
||||
|
||||
test("falls back to a safe positive default when the server omits limits (old servers)", () => {
|
||||
const raw = {
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
candidateCount: 3,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.ok(
|
||||
typeof entry.limit?.context === "number" && entry.limit.context > 0,
|
||||
`context must be a positive number (never 0 — zero disables opencode auto-compaction), got ${entry.limit?.context}`
|
||||
);
|
||||
assert.ok(
|
||||
typeof entry.limit?.output === "number" && entry.limit.output > 0,
|
||||
`output must be a positive number, got ${entry.limit?.output}`
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores non-positive server values and keeps the safe fallback", () => {
|
||||
const raw = {
|
||||
id: "auto/fast",
|
||||
name: "Auto Fast",
|
||||
variant: "fast",
|
||||
candidateCount: 2,
|
||||
context_length: 0,
|
||||
max_output_tokens: -1,
|
||||
} as OmniRouteRawAutoCombo;
|
||||
|
||||
const entry = mapAutoComboToStaticEntry(raw);
|
||||
assert.ok(
|
||||
typeof entry.limit?.context === "number" && entry.limit.context > 0,
|
||||
"zero/negative server values must not propagate"
|
||||
);
|
||||
assert.ok(typeof entry.limit?.output === "number" && entry.limit.output > 0);
|
||||
});
|
||||
@@ -1,641 +0,0 @@
|
||||
/**
|
||||
* T-05 combo-discovery contract tests.
|
||||
*
|
||||
* Covers:
|
||||
* - `defaultOmniRouteCombosFetcher(baseURL, apiKey, timeoutMs?)`
|
||||
* — envelope tolerance (`{combos: [...]}` and bare array), non-2xx errors.
|
||||
* - `mapComboToModelV2(combo, members, providerId, baseURL)`
|
||||
* — LCD policy across capabilities, limits, modalities; defensive
|
||||
* posture on empty members; nice-name preference.
|
||||
* - `createOmniRouteProviderHook(opts, deps)` extension
|
||||
* — combos merged into the models map; collision resolution (combo
|
||||
* wins, warn-once); soft-fail when the combos fetcher throws;
|
||||
* combos cached + reused under the same TTL key as models.
|
||||
*
|
||||
* Mocking strategy mirrors `provider.test.ts`: both fetchers are
|
||||
* dependency-injected at hook construction, no `fetch` monkey-patch.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
defaultOmniRouteCombosFetcher,
|
||||
mapComboToModelV2,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteModelsFetcher,
|
||||
type OmniRouteRawCombo,
|
||||
type OmniRouteRawModelEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Fixtures
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const MODEL_PRIMARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-primary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: true,
|
||||
vision: true,
|
||||
thinking: true,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 200_000,
|
||||
max_output_tokens: 64_000,
|
||||
max_input_tokens: 180_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_SECONDARY: OmniRouteRawModelEntry = {
|
||||
id: "claude-secondary",
|
||||
capabilities: {
|
||||
tool_calling: true,
|
||||
reasoning: false,
|
||||
vision: true,
|
||||
thinking: false,
|
||||
temperature: true,
|
||||
},
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 32_000,
|
||||
max_input_tokens: 96_000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const MODEL_NO_TOOLS: OmniRouteRawModelEntry = {
|
||||
id: "gemini-3-flash",
|
||||
capabilities: { tool_calling: false, reasoning: false, vision: false, thinking: false },
|
||||
context_length: 1_000_000,
|
||||
max_output_tokens: 8_192,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
|
||||
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
|
||||
id: "combo-claude-tier",
|
||||
name: "Claude Tier",
|
||||
strategy: "priority",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "claude-primary", weight: 100 },
|
||||
{ id: "s2", kind: "model", model: "claude-secondary", weight: 80 },
|
||||
],
|
||||
};
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function stubModelsFetcher(
|
||||
payload: OmniRouteRawModelEntry[]
|
||||
): OmniRouteModelsFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteModelsFetcher = async () => {
|
||||
n++;
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
function stubCombosFetcher(
|
||||
payload: OmniRouteRawCombo[]
|
||||
): OmniRouteCombosFetcher & { callCount: () => number; callsBy: () => Array<[string, string]> } {
|
||||
let n = 0;
|
||||
const calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteCombosFetcher = async (baseURL, apiKey) => {
|
||||
n++;
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => n,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
function failingCombosFetcher(
|
||||
err = new Error("boom")
|
||||
): OmniRouteCombosFetcher & { callCount: () => number } {
|
||||
let n = 0;
|
||||
const f: OmniRouteCombosFetcher = async () => {
|
||||
n++;
|
||||
throw err;
|
||||
};
|
||||
return Object.assign(f, { callCount: () => n });
|
||||
}
|
||||
|
||||
const apiAuth = (key: string): unknown => ({ type: "api", key });
|
||||
|
||||
// Capture console.warn invocations for the duration of a callback, then
|
||||
// restore the original. Needed because the collision + soft-fail paths
|
||||
// emit warnings we want to assert on.
|
||||
async function withWarnCapture<T>(
|
||||
fn: (warnings: Array<{ args: unknown[] }>) => Promise<T>
|
||||
): Promise<{ result: T; warnings: Array<{ args: unknown[] }> }> {
|
||||
const original = console.warn;
|
||||
const warnings: Array<{ args: unknown[] }> = [];
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push({ args });
|
||||
};
|
||||
try {
|
||||
const result = await fn(warnings);
|
||||
return { result, warnings };
|
||||
} finally {
|
||||
console.warn = original;
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// defaultOmniRouteCombosFetcher — envelope tolerance + error surfacing
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: parses {combos:[…]} envelope", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
const url = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
assert.equal(url, "https://or.example.com/api/combos");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
combos: [
|
||||
{ id: "c1", name: "Combo One", strategy: "priority", models: [] },
|
||||
{ id: "c2", name: "Combo Two", strategy: "weighted", models: [] },
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-test");
|
||||
assert.equal(combos.length, 2);
|
||||
assert.equal(combos[0].id, "c1");
|
||||
assert.equal(combos[1].id, "c2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: parses bare array envelope", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify([{ id: "c1" }, { id: "c2" }, { not_an_id: 42 }]), {
|
||||
status: 200,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const combos = await defaultOmniRouteCombosFetcher("https://or.example.com/v1", "sk-test");
|
||||
// Strip /v1 before /api/combos, AND filter out entries with no string id.
|
||||
assert.equal(combos.length, 2);
|
||||
assert.equal(combos[0].id, "c1");
|
||||
assert.equal(combos[1].id, "c2");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: strips trailing /v1 before /api/combos", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let observedUrl = "";
|
||||
globalThis.fetch = (async (input: unknown) => {
|
||||
observedUrl = typeof input === "string" ? input : (input as { url: string }).url;
|
||||
return new Response(JSON.stringify({ combos: [] }), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await defaultOmniRouteCombosFetcher("https://or.example.com/v1/", "sk-test");
|
||||
assert.equal(observedUrl, "https://or.example.com/api/combos");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws on non-2xx with status code in message", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async () => {
|
||||
return new Response(JSON.stringify({ error: "Invalid token" }), {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await defaultOmniRouteCombosFetcher("https://or.example.com", "sk-bad");
|
||||
},
|
||||
(err: unknown) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
assert.match(msg, /403/, "status code must appear in message");
|
||||
assert.match(msg, /\/api\/combos/, "url must appear in message");
|
||||
return true;
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws when apiKey missing", async () => {
|
||||
await assert.rejects(
|
||||
async () => defaultOmniRouteCombosFetcher("https://or.example.com", ""),
|
||||
/apiKey required/
|
||||
);
|
||||
});
|
||||
|
||||
test("defaultOmniRouteCombosFetcher: throws when baseURL missing", async () => {
|
||||
await assert.rejects(
|
||||
async () => defaultOmniRouteCombosFetcher("", "sk-test"),
|
||||
/baseURL required/
|
||||
);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// mapComboToModelV2 — LCD semantics
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("mapComboToModelV2: empty members → capabilities all false (defensive)", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "combo-empty", name: "Empty Combo" },
|
||||
[],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.id, "combo-empty");
|
||||
assert.equal(m.name, "Empty Combo");
|
||||
assert.equal(m.capabilities.temperature, false);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
assert.equal(m.capabilities.attachment, false);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
assert.equal(m.capabilities.input.text, false);
|
||||
assert.equal(m.capabilities.output.text, false);
|
||||
assert.equal(m.limit.context, 0);
|
||||
assert.equal(m.limit.output, 0);
|
||||
assert.equal(m.limit.input, undefined);
|
||||
assert.deepEqual(m.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: all members reasoning=true → combo reasoning=true", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[
|
||||
MODEL_PRIMARY,
|
||||
{
|
||||
...MODEL_PRIMARY,
|
||||
id: "p2",
|
||||
capabilities: { ...MODEL_PRIMARY.capabilities, thinking: false, reasoning: true },
|
||||
},
|
||||
],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: any member reasoning=false → combo reasoning=false", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash has reasoning:false, thinking:false
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: limit.context is min of members'", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
// min(200_000, 100_000, 1_000_000) = 100_000
|
||||
assert.equal(m.limit.context, 100_000);
|
||||
// min(64_000, 32_000, 8_192) = 8_192
|
||||
assert.equal(m.limit.output, 8_192);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: limit.input only emitted when EVERY member declares one", () => {
|
||||
const m1 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
// Both declare max_input_tokens → limit.input = min(180000, 96000)
|
||||
assert.equal(m1.limit.input, 96_000);
|
||||
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS], // gemini-3-flash doesn't declare max_input_tokens
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.limit.input, undefined);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: nice name preferred from combo.name", () => {
|
||||
const m1 = mapComboToModelV2(
|
||||
{ id: "combo-x", name: "Pretty Name" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m1.name, "Pretty Name");
|
||||
|
||||
// Falls back to id when name is absent or empty.
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "combo-y" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.name, "combo-y");
|
||||
|
||||
const m3 = mapComboToModelV2(
|
||||
{ id: "combo-z", name: " " },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m3.name, "combo-z");
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: attachment AND vision flag both honored across members", () => {
|
||||
// MODEL_PRIMARY: vision=true; MODEL_SECONDARY: vision=true → combo attachment=true
|
||||
const yes = mapComboToModelV2(
|
||||
{ id: "c1", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(yes.capabilities.attachment, true);
|
||||
|
||||
// Add a member with no vision/attachment → AND collapses to false
|
||||
const no = mapComboToModelV2(
|
||||
{ id: "c2", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(no.capabilities.attachment, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: modalities AND'd across members", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_SECONDARY], // both have text+image
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.input.text, true);
|
||||
assert.equal(m.capabilities.input.image, true);
|
||||
assert.equal(m.capabilities.input.audio, false);
|
||||
|
||||
// Add a text-only member → image collapses to false.
|
||||
const m2 = mapComboToModelV2(
|
||||
{ id: "c", models: [] },
|
||||
[MODEL_PRIMARY, MODEL_NO_TOOLS],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m2.capabilities.input.text, true);
|
||||
assert.equal(m2.capabilities.input.image, false);
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: api block matches providerId + baseURL", () => {
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c" },
|
||||
[MODEL_PRIMARY],
|
||||
"omniroute-preprod",
|
||||
"https://or-preprod.example.com/v1"
|
||||
);
|
||||
assert.equal(m.providerID, "omniroute-preprod");
|
||||
assert.equal(m.api.id, "openai-compatible");
|
||||
assert.equal(m.api.url, "https://or-preprod.example.com/v1");
|
||||
assert.equal(m.api.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(m.status, "active");
|
||||
});
|
||||
|
||||
test("mapComboToModelV2: explicit member temperature=false drops combo temperature=false", () => {
|
||||
const tempFalse: OmniRouteRawModelEntry = {
|
||||
id: "no-temp",
|
||||
capabilities: { tool_calling: true, temperature: false },
|
||||
context_length: 100_000,
|
||||
max_output_tokens: 8_000,
|
||||
input_modalities: ["text"],
|
||||
output_modalities: ["text"],
|
||||
};
|
||||
const m = mapComboToModelV2(
|
||||
{ id: "c" },
|
||||
[MODEL_PRIMARY, tempFalse],
|
||||
"omniroute",
|
||||
"https://or.example.com/v1"
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createOmniRouteProviderHook — combos merge + collision + soft-fail + cache
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("models() returns combo entries merged into the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY, MODEL_NO_TOOLS]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
assert.ok(out["gemini-3-flash"]);
|
||||
assert.ok(out["combo/claude-tier"]);
|
||||
|
||||
const combo = out["combo/claude-tier"];
|
||||
assert.equal(combo.name, "Combo: Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
assert.equal(combo.capabilities.toolcall, true);
|
||||
});
|
||||
|
||||
test("models(): combo with unknown member ids degrades to all-false LCD posture", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]); // catalog only has claude-primary
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "phantom",
|
||||
name: "Phantom Combo",
|
||||
models: [
|
||||
{ id: "s1", kind: "model", model: "does-not-exist-1", weight: 50 },
|
||||
{ id: "s2", kind: "model", model: "does-not-exist-2", weight: 50 },
|
||||
],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["combo/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["combo/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([
|
||||
{
|
||||
id: "visible",
|
||||
name: "Visible",
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
{
|
||||
id: "hidden",
|
||||
name: "Hidden",
|
||||
isHidden: true,
|
||||
models: [{ id: "s1", kind: "model", model: "claude-primary", weight: 100 }],
|
||||
},
|
||||
]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["combo/visible"]);
|
||||
assert.ok(!out["combo/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, combo lives at combo/ key, no warn", async () => {
|
||||
// Combo.name === raw model id triggers the dedup deletion. This mirrors
|
||||
// the real OmniRoute payload where /v1/models pre-mirrors combos as
|
||||
// no-slash raw entries whose ids match /api/combos friendly names.
|
||||
const colliderCombo: OmniRouteRawCombo = {
|
||||
id: "uuid-collider",
|
||||
name: "claude-primary", // EXACT match to MODEL_PRIMARY.id
|
||||
models: [{ id: "s1", kind: "model", model: "claude-secondary", weight: 100 }],
|
||||
};
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([colliderCombo]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async (_w) => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Raw model deleted by combo-name dedup; combo surfaces under combo/<slug>.
|
||||
assert.equal(out["claude-primary"], undefined, "raw deleted by combo-name dedup");
|
||||
assert.ok(out["combo/claude-primary"], "combo surfaces under combo/ namespace");
|
||||
assert.equal(out["combo/claude-primary"].name, "Combo: claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("collides");
|
||||
});
|
||||
assert.equal(collisionWarns.length, 0, "no collision warn after dedup");
|
||||
});
|
||||
|
||||
test("models(): two combos with same slug → second gets disambiguator suffix", async () => {
|
||||
// Both combos slug to `claude` — second must get `combo/claude-<id-prefix>`.
|
||||
const combos: OmniRouteRawCombo[] = [
|
||||
{
|
||||
id: "uuid-a",
|
||||
name: "Claude",
|
||||
models: [{ id: "s", kind: "model", model: "claude-primary", weight: 1 }],
|
||||
},
|
||||
{
|
||||
id: "uuid-b",
|
||||
name: "Claude",
|
||||
models: [{ id: "s", kind: "model", model: "claude-secondary", weight: 1 }],
|
||||
},
|
||||
];
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]),
|
||||
combosFetcher: stubCombosFetcher(combos),
|
||||
}
|
||||
);
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["combo/claude"], "first combo at bare slug");
|
||||
assert.ok(out["combo/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = failingCombosFetcher(new Error("ECONNRESET"));
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
|
||||
const { result: out, warnings } = await withWarnCapture(async () => {
|
||||
return hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
});
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["claude-primary"]);
|
||||
assert.ok(out["claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
const msg = w.args[0];
|
||||
return typeof msg === "string" && msg.includes("combos fetch failed");
|
||||
});
|
||||
assert.ok(softFail, "soft-fail warning must be emitted on combos fetch error");
|
||||
assert.equal(combosFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
test("models(): combos cached + reused within TTL (one combo fetch per TTL window)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY, MODEL_SECONDARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["combo/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher: modelsFetcher, combosFetcher, now: () => nowMs }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001;
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 2, "combos must refetch past TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 2, "models must refetch past TTL");
|
||||
});
|
||||
|
||||
test("models(): combos fetcher receives the resolved baseURL + apiKey", async () => {
|
||||
const modelsFetcher = stubModelsFetcher([MODEL_PRIMARY]);
|
||||
const combosFetcher = stubCombosFetcher([COMBO_CLAUDE_TIER]);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-spy") as never });
|
||||
assert.deepEqual(combosFetcher.callsBy()[0], ["https://or.example.com/v1", "sk-spy"]);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Regression test for the disk-snapshot file permissions (release/v3.8.2
|
||||
* review finding C2). The snapshot embeds provider topology + connection
|
||||
* records and lives alongside auth.json (0o600), so it must NOT be readable by
|
||||
* group/other. Before the fix it was written with the default (typically
|
||||
* world-readable 0o644) mode.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
defaultDiskSnapshotWriter,
|
||||
diskSnapshotPath,
|
||||
type OmniRouteFetchCacheEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
function makeEntry(): Omit<OmniRouteFetchCacheEntry, "expiresAt"> {
|
||||
return {
|
||||
rawModels: [],
|
||||
rawCombos: [],
|
||||
rawEnrichment: new Map(),
|
||||
rawCompressionCombos: [],
|
||||
rawConnections: [],
|
||||
};
|
||||
}
|
||||
|
||||
test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot", async (t) => {
|
||||
// POSIX-only assertion; Windows does not honor numeric file modes.
|
||||
if (process.platform === "win32") {
|
||||
t.skip("file mode semantics are POSIX-only");
|
||||
return;
|
||||
}
|
||||
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-disk-perms-"));
|
||||
const prevDataDir = process.env.OPENCODE_DATA_DIR;
|
||||
process.env.OPENCODE_DATA_DIR = tmp;
|
||||
|
||||
try {
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry());
|
||||
|
||||
const file = diskSnapshotPath("perm-test");
|
||||
assert.ok(fs.existsSync(file), "snapshot file should be written");
|
||||
|
||||
const fileMode = fs.statSync(file).mode & 0o777;
|
||||
assert.equal(
|
||||
fileMode & 0o077,
|
||||
0,
|
||||
`snapshot must not be group/other accessible (got ${fileMode.toString(8)})`
|
||||
);
|
||||
|
||||
const dirMode = fs.statSync(path.dirname(file)).mode & 0o777;
|
||||
assert.equal(
|
||||
dirMode & 0o077,
|
||||
0,
|
||||
`plugins dir must not be group/other accessible (got ${dirMode.toString(8)})`
|
||||
);
|
||||
} finally {
|
||||
if (prevDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
|
||||
else process.env.OPENCODE_DATA_DIR = prevDataDir;
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* T-04 fetch-interceptor contract tests.
|
||||
*
|
||||
* Covers `createOmniRouteFetchInterceptor` (URL-prefix gating, header merge,
|
||||
* Content-Type defaulting, input-shape polymorphism) plus the loader
|
||||
* integration that wires it into the AuthHook return shape.
|
||||
*
|
||||
* Strategy: replace `globalThis.fetch` with a closure-based recorder for the
|
||||
* duration of each test (saved-and-restored in try/finally — node:test has
|
||||
* no built-in spy/restore lifecycle). The recorder captures `(input, init)`
|
||||
* as observed by the wrapped global call so we can assert on what was
|
||||
* forwarded after header injection.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createOmniRouteAuthHook, createOmniRouteFetchInterceptor } from "../src/index.js";
|
||||
|
||||
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
|
||||
|
||||
function installFetchRecorder(response: Response = new Response("ok")) {
|
||||
const calls: FetchCall[] = [];
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
calls.push({ input, init });
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
const restore = () => {
|
||||
globalThis.fetch = original;
|
||||
};
|
||||
return { calls, restore };
|
||||
}
|
||||
|
||||
const BASE = "https://or.example.com/v1";
|
||||
const KEY = "sk-test-fetch";
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header injected", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ x: 1 }),
|
||||
});
|
||||
assert.equal(calls.length, 1);
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: { Authorization: "Bearer attacker-key" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
// We own the apiKey for this provider — caller-supplied Bearer must lose.
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: targets baseURL + body → Content-Type defaults to application/json", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ m: "x" }),
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Content-Type"), "application/json");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: caller-set Content-Type is NOT overwritten", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/v2/whatever`, {
|
||||
method: "POST",
|
||||
body: "raw",
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Content-Type"), "text/plain; charset=utf-8");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: non-baseURL host → passthrough, no Authorization injected", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f("https://third-party.example.org/v1/chat", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
headers: { "X-Caller": "yes" },
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
// Init forwarded verbatim — no header injection.
|
||||
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), null, "MUST NOT leak apiKey");
|
||||
assert.equal(sentHeaders.get("X-Caller"), "yes");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: refuses suffix-spoof — `${base}-attacker.evil` does NOT match baseURL", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
// baseURL is `https://or.example.com/v1`. A spoofed
|
||||
// `https://or.example.com/v1-attacker.evil/chat` shares the literal prefix
|
||||
// but is NOT under our origin path — must be treated as passthrough.
|
||||
await f("https://or.example.com/v1-attacker.evil/chat", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit | undefined)?.headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: URL object input is handled", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(new URL(`${BASE}/models`), {});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: Request input is handled (reads .url)", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
const req = new Request(`${BASE}/chat/completions`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ a: 1 }),
|
||||
headers: { "X-Caller": "preserved" },
|
||||
});
|
||||
await f(req);
|
||||
const sent = calls[0]!;
|
||||
// The interceptor forwards the original Request as `input` but layers our
|
||||
// headers into the `init`. We assert against the init view since fetch()
|
||||
// resolves headers from init first when both are present.
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
assert.equal(
|
||||
sentHeaders.get("X-Caller"),
|
||||
"preserved",
|
||||
"Request-attached headers must survive the merge"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: trailing slash in baseURL is normalized", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({
|
||||
apiKey: KEY,
|
||||
baseURL: `${BASE}////`,
|
||||
});
|
||||
await f(`${BASE}/models`, {});
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteFetchInterceptor: GET without body does NOT set Content-Type", async () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const f = createOmniRouteFetchInterceptor({ apiKey: KEY, baseURL: BASE });
|
||||
await f(`${BASE}/models`); // no init at all
|
||||
const sent = calls[0]!;
|
||||
const sentHeaders = new Headers((sent.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
assert.equal(
|
||||
sentHeaders.get("Content-Type"),
|
||||
null,
|
||||
"Content-Type should only default when a body exists"
|
||||
);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// loader integration
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
test("loader: returns fetch fn when apiKey + baseURL both present (via opts)", async () => {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: BASE });
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
|
||||
assert.equal((result as { apiKey: string }).apiKey, KEY);
|
||||
assert.equal((result as { baseURL: string }).baseURL, BASE);
|
||||
assert.equal(
|
||||
typeof (result as { fetch?: unknown }).fetch,
|
||||
"function",
|
||||
"loader must wire fetch interceptor when baseURL resolves"
|
||||
);
|
||||
});
|
||||
|
||||
test("loader: returns fetch fn when baseURL is stashed on the auth credential", async () => {
|
||||
// Some auth backends attach baseURL alongside the key (post-/connect flow).
|
||||
// The loader should pick it up even when plugin opts.baseURL is unset.
|
||||
const hook = createOmniRouteAuthHook();
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: KEY, baseURL: BASE }) as never,
|
||||
{} as never
|
||||
);
|
||||
assert.equal((result as { baseURL?: string }).baseURL, BASE);
|
||||
assert.equal(typeof (result as { fetch?: unknown }).fetch, "function");
|
||||
});
|
||||
|
||||
test("loader: omits fetch fn when baseURL missing (apiKey-only return)", async () => {
|
||||
const hook = createOmniRouteAuthHook(); // no baseURL opt
|
||||
const result = await hook.loader!(async () => ({ type: "api", key: KEY }) as never, {} as never);
|
||||
// Interceptor needs a baseURL to gate-keep; without one, fall back to
|
||||
// apiKey-only and let the SDK use its default fetch.
|
||||
assert.deepEqual(result, { apiKey: KEY });
|
||||
});
|
||||
|
||||
test("loader integration: wired interceptor actually injects Bearer when invoked", async () => {
|
||||
// End-to-end: pull the fetch fn out of the loader return and exercise it,
|
||||
// proving the wiring matches the standalone interceptor's contract.
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
const hook = createOmniRouteAuthHook({ baseURL: BASE });
|
||||
const result = await hook.loader!(
|
||||
async () => ({ type: "api", key: KEY }) as never,
|
||||
{} as never
|
||||
);
|
||||
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
|
||||
await wiredFetch(`${BASE}/v1/models`, {});
|
||||
assert.equal(calls.length, 1);
|
||||
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Tests for the 3 mrmm-fork features backported to @omniroute/opencode-plugin:
|
||||
*
|
||||
* 1. `normaliseFreeLabel` — free-tier model display names get a consistent
|
||||
* `[Free] ` prefix instead of trailing "(Free)" or ad-hoc "free" words.
|
||||
*
|
||||
* 2. `resolveApiBlock` — per-provider-prefix API format routing. Anthropic
|
||||
* prefixes (`cc/`, `claude/`, `anthropic/`, `kiro/`, `kr/`) get the
|
||||
* Anthropic SDK block; everything else gets OpenAI-compat.
|
||||
*
|
||||
* 3. `debugLog` — JSONL request/response capture, gated by
|
||||
* `features.debugLog` and togglable at runtime via
|
||||
* `debugLogEnabled/SetEnabled`.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normaliseFreeLabel,
|
||||
resolveApiBlock,
|
||||
DEFAULT_ANTHROPIC_PREFIXES,
|
||||
ensureV1Suffix,
|
||||
debugLogEnabled,
|
||||
debugLogSetEnabled,
|
||||
debugLogClear,
|
||||
debugLogRead,
|
||||
debugLogAppend,
|
||||
createDebugLoggingFetch,
|
||||
DebugLogEntry,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ── 1. normaliseFreeLabel ────────────────────────────────────────────────────
|
||||
|
||||
test("normaliseFreeLabel: '(Free)' suffix becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("GPT-4.1 (Free)"), "[Free] GPT-4.1");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing ' Free' word becomes [Free] prefix", () => {
|
||||
assert.equal(
|
||||
normaliseFreeLabel("DeepSeek V4 Flash Free"),
|
||||
"[Free] DeepSeek V4 Flash"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: trailing '-free' (hyphen) becomes [Free] prefix", () => {
|
||||
assert.equal(normaliseFreeLabel("Llama 4 Scout-free"), "[Free] Llama 4 Scout");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: case-insensitive (FREE, Free, free all match)", () => {
|
||||
assert.equal(normaliseFreeLabel("Model A FREE"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A free"), "[Free] Model A");
|
||||
assert.equal(normaliseFreeLabel("Model A Free"), "[Free] Model A");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: names without 'free' pass through unchanged", () => {
|
||||
assert.equal(normaliseFreeLabel("Claude 4.7 Opus"), "Claude 4.7 Opus");
|
||||
assert.equal(normaliseFreeLabel("GPT-5"), "GPT-5");
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: 'free' in the middle of a name is NOT rewritten", () => {
|
||||
// Only trailing/standalone "free" markers count; embedded "freedom" stays
|
||||
assert.equal(
|
||||
normaliseFreeLabel("Freedom Model"),
|
||||
"Freedom Model"
|
||||
);
|
||||
});
|
||||
|
||||
test("normaliseFreeLabel: empty / whitespace-only inputs are handled", () => {
|
||||
// Empty input returns empty; pure whitespace input passes through (no Free marker)
|
||||
assert.equal(normaliseFreeLabel(""), "");
|
||||
assert.equal(normaliseFreeLabel(" "), " ");
|
||||
});
|
||||
|
||||
// ── 2. resolveApiBlock ───────────────────────────────────────────────────────
|
||||
|
||||
test("resolveApiBlock: cc/* models get the Anthropic SDK block (no /v1)", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus-4-7", "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
assert.equal(block.url, "https://api.example.com"); // NO /v1 suffix
|
||||
});
|
||||
|
||||
test("resolveApiBlock: claude/*, anthropic/*, kiro/*, kr/* all route to Anthropic", () => {
|
||||
for (const id of [
|
||||
"claude/claude-opus-4-7",
|
||||
"anthropic/claude-sonnet-4",
|
||||
"kiro/claude-sonnet-4-5",
|
||||
"kr/claude-opus-4-6",
|
||||
]) {
|
||||
const block = resolveApiBlock(id, "https://api.example.com");
|
||||
assert.equal(block.id, "anthropic", `${id} should route to Anthropic`);
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveApiBlock: non-Anthropic models get OpenAI-compat with /v1", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com");
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
assert.equal(block.npm, "@ai-sdk/openai-compatible");
|
||||
assert.equal(block.url, "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: user can override anthropicPrefixes to add custom prefixes", () => {
|
||||
const block = resolveApiBlock("myproxy/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: ["myproxy"],
|
||||
});
|
||||
assert.equal(block.id, "anthropic");
|
||||
assert.equal(block.npm, "@ai-sdk/anthropic");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: empty anthropicPrefixes forces OpenAI-compat for everything", () => {
|
||||
const block = resolveApiBlock("cc/claude-opus", "https://api.example.com", {
|
||||
anthropicPrefixes: [],
|
||||
});
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("resolveApiBlock: baseURL that already ends in /v1 is not double-suffixed (OpenAI path)", () => {
|
||||
const block = resolveApiBlock("gpt-4o", "https://api.example.com/v1");
|
||||
assert.equal(block.url, "https://api.example.com/v1"); // idempotent
|
||||
});
|
||||
|
||||
test("resolveApiBlock: model id without '/' uses the id as prefix", () => {
|
||||
const block = resolveApiBlock("claude-opus-4-7", "https://api.example.com");
|
||||
// The whole id is the prefix, which doesn't match "cc"/"claude" etc.
|
||||
// So it falls through to OpenAI-compat.
|
||||
assert.equal(block.id, "openai-compatible");
|
||||
});
|
||||
|
||||
test("DEFAULT_ANTHROPIC_PREFIXES: contains the canonical Anthropic aliases", () => {
|
||||
assert.deepEqual(DEFAULT_ANTHROPIC_PREFIXES, [
|
||||
"cc",
|
||||
"claude",
|
||||
"anthropic",
|
||||
"kiro",
|
||||
"kr",
|
||||
]);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: idempotent for URLs that already end in /v1", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/v1"), "https://api.example.com/v1");
|
||||
assert.equal(
|
||||
ensureV1Suffix("https://api.example.com/v1/"),
|
||||
"https://api.example.com/v1" // trailing slash is stripped
|
||||
);
|
||||
});
|
||||
|
||||
test("ensureV1Suffix: appends /v1 when missing", () => {
|
||||
assert.equal(ensureV1Suffix("https://api.example.com"), "https://api.example.com/v1");
|
||||
assert.equal(ensureV1Suffix("https://api.example.com/"), "https://api.example.com/v1");
|
||||
});
|
||||
|
||||
// ── 3. debugLog ──────────────────────────────────────────────────────────────
|
||||
|
||||
test("debugLog: default state is disabled", () => {
|
||||
debugLogClear("test-provider-disabled-default");
|
||||
assert.equal(debugLogEnabled("test-provider-disabled-default"), false);
|
||||
});
|
||||
|
||||
test("debugLogSetEnabled + debugLogEnabled: roundtrip", () => {
|
||||
debugLogSetEnabled("test-provider-toggle", true);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), true);
|
||||
debugLogSetEnabled("test-provider-toggle", false);
|
||||
assert.equal(debugLogEnabled("test-provider-toggle"), false);
|
||||
});
|
||||
|
||||
test("debugLogAppend + debugLogRead: roundtrip preserves entry shape", () => {
|
||||
const providerId = "test-provider-readroundtrip";
|
||||
debugLogClear(providerId);
|
||||
const entry: DebugLogEntry = {
|
||||
reqId: "req-1",
|
||||
providerId,
|
||||
ts: 1700000000000,
|
||||
url: "https://api.example.com/v1/chat",
|
||||
method: "POST",
|
||||
reqHeaders: { "content-type": "application/json" },
|
||||
reqBody: { model: "gpt-4o", messages: [] },
|
||||
resStatus: 200,
|
||||
resHeaders: { "content-type": "application/json" },
|
||||
resBody: { choices: [] },
|
||||
durationMs: 42,
|
||||
};
|
||||
debugLogAppend(entry);
|
||||
const read = debugLogRead(providerId, 10);
|
||||
assert.equal(read.length, 1);
|
||||
assert.deepEqual(read[0], entry);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: passes through when disabled", async () => {
|
||||
const providerId = "test-provider-passthrough";
|
||||
debugLogClear(providerId);
|
||||
debugLogSetEnabled(providerId, false);
|
||||
const calls: unknown[] = [];
|
||||
const inner: typeof fetch = async (input) => {
|
||||
calls.push(input);
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, false);
|
||||
const res = await wrapped("https://api.example.com/v1/chat");
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1);
|
||||
// No log entry should be written when disabled
|
||||
assert.equal(debugLogRead(providerId).length, 0);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: captures request/response when enabled", async () => {
|
||||
const providerId = "test-provider-captures";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-4o" }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].method, "POST");
|
||||
assert.equal(entries[0].resStatus, 200);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.deepEqual(entries[0].reqBody, { model: "gpt-4o" });
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: records error without crashing the wrapped fetch", async () => {
|
||||
const providerId = "test-provider-error";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await assert.rejects(wrapped("https://api.example.com/v1/chat"), /network down/);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resStatus, null);
|
||||
assert.equal(entries[0].error, "network down");
|
||||
});
|
||||
|
||||
// ── Regression tests for the 3 HIGH-priority bot review fixes ───────────────
|
||||
|
||||
test("createDebugLoggingFetch: URL instance input is captured (not 'undefined')", async () => {
|
||||
const providerId = "test-provider-url-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
await wrapped(new URL("https://api.example.com/v1/chat"));
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.notEqual(entries[0].url, undefined);
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: Request object input captures URL and headers", async () => {
|
||||
const providerId = "test-provider-request-input";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("ok", { status: 200 });
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const req = new Request("https://api.example.com/v1/chat", {
|
||||
method: "POST",
|
||||
headers: { "x-test": "yes" },
|
||||
});
|
||||
await wrapped(req);
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].url, "https://api.example.com/v1/chat");
|
||||
assert.equal(entries[0].reqHeaders["x-test"], "yes");
|
||||
});
|
||||
|
||||
test("createDebugLoggingFetch: SSE response is NOT buffered (resBody is the stream marker)", async () => {
|
||||
const providerId = "test-provider-sse";
|
||||
debugLogClear(providerId);
|
||||
const inner: typeof fetch = async () =>
|
||||
new Response("data: hello\n\n", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
});
|
||||
const wrapped = createDebugLoggingFetch(inner, providerId, true);
|
||||
const res = await wrapped("https://api.example.com/v1/stream");
|
||||
// The response body must remain readable downstream
|
||||
const txt = await res.text();
|
||||
assert.equal(txt, "data: hello\n\n");
|
||||
const entries = debugLogRead(providerId);
|
||||
assert.equal(entries.length, 1);
|
||||
assert.equal(entries[0].resBody, "[stream]", "SSE responses must not be buffered");
|
||||
});
|
||||
@@ -1,410 +0,0 @@
|
||||
/**
|
||||
* T-06 Gemini tool-schema sanitisation contract tests.
|
||||
*
|
||||
* Three layers under test:
|
||||
* 1. `sanitizeGeminiToolSchemas` — pure function; key stripping + clone
|
||||
* semantics on chat-completion + Responses-API shapes.
|
||||
* 2. `shouldSanitizeForGemini` — model-string detection (liberal).
|
||||
* 3. `createGeminiSanitizingFetch` — wrapper composition; URL gating,
|
||||
* body-shape polymorphism, streaming-body bypass, fail-open behaviour,
|
||||
* composition with the T-04 Bearer interceptor.
|
||||
*
|
||||
* Strategy: same posture as fetch-interceptor.test.ts — install a
|
||||
* closure-based fetch recorder; assert on the `(input, init)` observed by
|
||||
* the inner fetch after the sanitising wrapper has had its say.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
__resetGeminiStreamingWarning,
|
||||
createGeminiSanitizingFetch,
|
||||
createOmniRouteFetchInterceptor,
|
||||
sanitizeGeminiToolSchemas,
|
||||
shouldSanitizeForGemini,
|
||||
} from "../src/index.js";
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FetchCall = { input: Parameters<typeof fetch>[0]; init?: RequestInit };
|
||||
|
||||
function recorder(response: Response = new Response("ok")): {
|
||||
fn: typeof fetch;
|
||||
calls: FetchCall[];
|
||||
} {
|
||||
const calls: FetchCall[] = [];
|
||||
const fn = (async (input: any, init?: any) => {
|
||||
calls.push({ input, init });
|
||||
return response;
|
||||
}) as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
function bodyAsRecord(init: RequestInit | undefined): Record<string, unknown> {
|
||||
const b = init?.body;
|
||||
if (typeof b !== "string") {
|
||||
throw new Error(`expected string body, got ${typeof b}`);
|
||||
}
|
||||
return JSON.parse(b) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Sample tool payloads — small enough to inline, big enough to cover
|
||||
// chat-completion + Responses-API + nested properties.
|
||||
|
||||
function chatCompletionsWithDollarSchema(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-2.5-pro",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "search",
|
||||
parameters: {
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
q: { type: "string" },
|
||||
},
|
||||
required: ["q"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function responsesApiWithRef(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-2.5-flash",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
name: "lookup",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
$ref: "#/definitions/Lookup",
|
||||
properties: {
|
||||
id: { type: "string", ref: "Id" },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function nestedPropertiesPayload(): Record<string, unknown> {
|
||||
return {
|
||||
model: "gemini-pro",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "deep",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
outer: {
|
||||
type: "object",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
properties: {
|
||||
inner: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
$ref: "#/inner",
|
||||
properties: {
|
||||
leaf: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// sanitizeGeminiToolSchemas — pure function
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips $schema from top-level", () => {
|
||||
const input = {
|
||||
model: "gemini-2.5-pro",
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
tools: [],
|
||||
};
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
assert.equal(out.$schema, undefined);
|
||||
assert.equal(out.model, "gemini-2.5-pro");
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips $ref + additionalProperties from tools[].function.parameters", () => {
|
||||
const input = chatCompletionsWithDollarSchema();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
|
||||
.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
// Untouched keys survive.
|
||||
assert.equal(params.type, "object");
|
||||
assert.deepEqual(params.required, ["q"]);
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: strips nested $schema from properties.x.properties.y", () => {
|
||||
const input = nestedPropertiesPayload();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const params = (out.tools as Array<{ function: { parameters: Record<string, unknown> } }>)[0]!
|
||||
.function.parameters;
|
||||
const outer = (params.properties as Record<string, Record<string, unknown>>).outer!;
|
||||
const inner = (outer.properties as Record<string, Record<string, unknown>>).inner!;
|
||||
assert.equal(outer.$schema, undefined);
|
||||
assert.equal(inner.$ref, undefined);
|
||||
assert.equal(inner.additionalProperties, undefined);
|
||||
// Leaf still intact.
|
||||
assert.deepEqual(inner.properties, { leaf: { type: "string" } });
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: handles Responses-API tools[].input_schema shape", () => {
|
||||
const input = responsesApiWithRef();
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
const inputSchema = (out.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
|
||||
.input_schema;
|
||||
assert.equal(inputSchema.$ref, undefined);
|
||||
// Nested `ref` (lowercase) also stripped.
|
||||
const props = inputSchema.properties as Record<string, Record<string, unknown>>;
|
||||
assert.equal(props.id!.ref, undefined);
|
||||
assert.equal(props.id!.type, "string");
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: leaves payload without tools untouched", () => {
|
||||
const input = { model: "gemini-2.5-pro", messages: [{ role: "user", content: "hi" }] };
|
||||
const out = sanitizeGeminiToolSchemas(input) as Record<string, unknown>;
|
||||
assert.deepEqual(out, input);
|
||||
});
|
||||
|
||||
test("sanitizeGeminiToolSchemas: does not mutate input (returned object is distinct)", () => {
|
||||
const input = chatCompletionsWithDollarSchema();
|
||||
const beforeJson = JSON.stringify(input);
|
||||
const out = sanitizeGeminiToolSchemas(input);
|
||||
// Input bit-identical to its pre-sanitise serialisation.
|
||||
assert.equal(JSON.stringify(input), beforeJson);
|
||||
// Output is a different reference.
|
||||
assert.notEqual(out, input);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// shouldSanitizeForGemini — detection
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("shouldSanitizeForGemini: gemini-2.5-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: models/gemini-pro → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "models/gemini-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: "claude-sonnet-4" }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model missing → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ messages: [] }), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload is null → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini(null), false);
|
||||
});
|
||||
|
||||
test("shouldSanitizeForGemini: payload.model is non-string → false", () => {
|
||||
assert.equal(shouldSanitizeForGemini({ model: 42 }), false);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// createGeminiSanitizingFetch — wrapper
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const URL_CHAT = "https://or.example.com/v1/chat/completions";
|
||||
const URL_RESPONSES = "https://or.example.com/v1/responses";
|
||||
const URL_MODELS = "https://or.example.com/v1/models";
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + chat/completions → tool schemas stripped before forward", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
});
|
||||
assert.equal(rec.calls.length, 1);
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: non-gemini model + chat/completions → body passed through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
const originalBody = JSON.stringify({
|
||||
model: "claude-sonnet-4",
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "x",
|
||||
parameters: { $schema: "keep-me", type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await wrapped(URL_CHAT, { method: "POST", body: originalBody });
|
||||
// Identity check on body — wrapper must NOT mutate non-Gemini payloads.
|
||||
assert.equal(rec.calls[0]!.init!.body, originalBody);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + /v1/models (non-completion endpoint) → body passed through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
// GET /v1/models has no body in production; assert that even if a caller
|
||||
// attached a Gemini-shaped body to a non-completion URL, the wrapper
|
||||
// doesn't touch it.
|
||||
const body = JSON.stringify(chatCompletionsWithDollarSchema());
|
||||
await wrapped(URL_MODELS, { method: "POST", body });
|
||||
assert.equal(rec.calls[0]!.init!.body, body);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + /responses endpoint → input_schema stripped", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_RESPONSES, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(responsesApiWithRef()),
|
||||
});
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const schema = (forwarded.tools as Array<{ input_schema: Record<string, unknown> }>)[0]!
|
||||
.input_schema;
|
||||
assert.equal(schema.$ref, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + Request input with body → tool schemas stripped", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
const req = new Request(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
await wrapped(req);
|
||||
const forwarded = bodyAsRecord(rec.calls[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: gemini model + ReadableStream body → skipped + warn emitted once", async () => {
|
||||
__resetGeminiStreamingWarning();
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
|
||||
// Capture console.warn for the duration of this test.
|
||||
const warnings: string[] = [];
|
||||
const originalWarn = console.warn;
|
||||
console.warn = (...args: unknown[]) => {
|
||||
warnings.push(args.map(String).join(" "));
|
||||
};
|
||||
|
||||
try {
|
||||
const stream1 = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
const stream2 = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("{}"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
// Two streaming calls — only one warn expected.
|
||||
await wrapped(URL_CHAT, { method: "POST", body: stream1 });
|
||||
await wrapped(URL_CHAT, { method: "POST", body: stream2 });
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
}
|
||||
|
||||
// Both calls forwarded to inner fetch with their streams intact.
|
||||
assert.equal(rec.calls.length, 2);
|
||||
// ONE warning total — one-shot latch held.
|
||||
assert.equal(warnings.length, 1);
|
||||
assert.match(warnings[0]!, /streaming Request body, skipping schema strip/);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: invalid JSON body → pass through, no throw", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
// Garbage body must not crash the wrapper.
|
||||
await wrapped(URL_CHAT, { method: "POST", body: "this is not json{{" });
|
||||
assert.equal(rec.calls.length, 1);
|
||||
assert.equal(rec.calls[0]!.init!.body, "this is not json{{");
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: empty body → pass through unchanged", async () => {
|
||||
const rec = recorder();
|
||||
const wrapped = createGeminiSanitizingFetch(rec.fn);
|
||||
await wrapped(URL_CHAT, { method: "POST" });
|
||||
assert.equal(rec.calls.length, 1);
|
||||
});
|
||||
|
||||
test("createGeminiSanitizingFetch: composes correctly with createOmniRouteFetchInterceptor (Bearer + sanitization)", async () => {
|
||||
// Save and replace globalThis.fetch — the Bearer interceptor calls global
|
||||
// fetch when the URL targets its baseURL.
|
||||
const originalFetch = globalThis.fetch;
|
||||
const observed: FetchCall[] = [];
|
||||
globalThis.fetch = (async (input: any, init?: any) => {
|
||||
observed.push({ input, init });
|
||||
return new Response("ok");
|
||||
}) as typeof fetch;
|
||||
|
||||
try {
|
||||
const composed = createGeminiSanitizingFetch(
|
||||
createOmniRouteFetchInterceptor({
|
||||
apiKey: "sk-test",
|
||||
baseURL: "https://or.example.com/v1",
|
||||
})
|
||||
);
|
||||
await composed(URL_CHAT, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(chatCompletionsWithDollarSchema()),
|
||||
});
|
||||
|
||||
assert.equal(observed.length, 1);
|
||||
// Bearer injected (header concern).
|
||||
const sentHeaders = new Headers((observed[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), "Bearer sk-test");
|
||||
// Schema sanitised (body concern).
|
||||
const forwarded = bodyAsRecord(observed[0]!.init);
|
||||
const params = (
|
||||
forwarded.tools as Array<{ function: { parameters: Record<string, unknown> } }>
|
||||
)[0]!.function.parameters;
|
||||
assert.equal(params.$schema, undefined);
|
||||
assert.equal(params.additionalProperties, undefined);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* T-08 multi-instance smoke.
|
||||
*
|
||||
* Validates that two `OmniRoutePlugin(input, opts)` invocations with
|
||||
* different `providerId` values coexist without sharing mutable state.
|
||||
* This is the contract that lets opencode.json declare prod + preprod
|
||||
* side by side:
|
||||
*
|
||||
* "plugin": [
|
||||
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-prod", "baseURL": "https://or.example/v1"}],
|
||||
* ["@omniroute/opencode-plugin", {"providerId": "omniroute-preprod", "baseURL": "https://or-preprod.example/v1"}]
|
||||
* ]
|
||||
*
|
||||
* Assertions:
|
||||
* - Each invocation returns its own hooks object (no identity reuse).
|
||||
* - Each `auth` hook carries its own `provider` matching opts.providerId.
|
||||
* - Each `auth.methods` array is its own array (not the same reference).
|
||||
* - Calling the factory twice with IDENTICAL opts still yields two
|
||||
* independent objects (no instance reuse / no shared closure cache).
|
||||
* - Mutating one instance's auth hook does NOT bleed into the other.
|
||||
* - Each instance's loader closure captures its OWN baseURL — no
|
||||
* last-write-wins module-scope state.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { OmniRoutePlugin } from "../src/index.js";
|
||||
|
||||
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
|
||||
test("multi-instance: two plugin invocations bind to their own providerId", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-prod",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.equal(a.auth?.provider, "omniroute-prod");
|
||||
assert.equal(b.auth?.provider, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("multi-instance: hook objects + nested arrays are independent references", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "alpha",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "bravo",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
assert.notEqual(a, b, "top-level hooks objects must not be the same reference");
|
||||
assert.notEqual(a.auth, b.auth, "auth hooks must not be the same reference");
|
||||
assert.notEqual(
|
||||
a.auth?.methods,
|
||||
b.auth?.methods,
|
||||
"methods arrays must not be the same reference"
|
||||
);
|
||||
});
|
||||
|
||||
test("multi-instance: identical opts twice still yield independent objects", async () => {
|
||||
const opts = { providerId: "twin", baseURL: "https://twin.example/v1" };
|
||||
const first = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
const second = await OmniRoutePlugin(fakeInput, { ...opts });
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.notEqual(first.auth, second.auth);
|
||||
assert.notEqual(first.auth?.methods, second.auth?.methods);
|
||||
// Same provider id is fine — what matters is no shared mutable state.
|
||||
assert.equal(first.auth?.provider, "twin");
|
||||
assert.equal(second.auth?.provider, "twin");
|
||||
});
|
||||
|
||||
test("multi-instance: mutating instance A's auth.methods does not affect instance B", async () => {
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-a",
|
||||
baseURL: "https://a.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "iso-b",
|
||||
baseURL: "https://b.example/v1",
|
||||
});
|
||||
|
||||
const beforeLen = b.auth?.methods?.length ?? 0;
|
||||
// Mutate a's methods array — extend it; b's must be untouched.
|
||||
// We don't know the concrete method shape so push a sentinel cast.
|
||||
a.auth?.methods?.push({ type: "api", label: "sentinel" } as never);
|
||||
assert.equal(b.auth?.methods?.length, beforeLen, "instance B leaked from instance A mutation");
|
||||
});
|
||||
|
||||
test("multi-instance: loader closures see their own opts (not last-write-wins)", async () => {
|
||||
// Each plugin's loader builds its loader payload from the providerId/baseURL
|
||||
// captured at invocation time. If the factory accidentally shared a closure
|
||||
// (e.g. a module-scope let that the last invocation overwrites), both
|
||||
// loaders would emit the same baseURL. Verify they don't.
|
||||
const a = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-prod",
|
||||
baseURL: "https://prod.example/v1",
|
||||
});
|
||||
const b = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "omniroute-preprod",
|
||||
baseURL: "https://preprod.example/v1",
|
||||
});
|
||||
|
||||
assert.ok(a.auth?.loader, "instance A must have a loader");
|
||||
assert.ok(b.auth?.loader, "instance B must have a loader");
|
||||
|
||||
const getAuthA = async () => ({ type: "api", key: "sk-prod" }) as never;
|
||||
const getAuthB = async () => ({ type: "api", key: "sk-preprod" }) as never;
|
||||
|
||||
const rA = (await a.auth!.loader!(getAuthA, {} as never)) as Record<string, unknown>;
|
||||
const rB = (await b.auth!.loader!(getAuthB, {} as never)) as Record<string, unknown>;
|
||||
|
||||
assert.equal(rA.apiKey, "sk-prod");
|
||||
assert.equal(rA.baseURL, "https://prod.example/v1");
|
||||
assert.equal(rB.apiKey, "sk-preprod");
|
||||
assert.equal(rB.baseURL, "https://preprod.example/v1");
|
||||
});
|
||||
|
||||
test("multi-instance: invalid opts on one instance does not poison the other", async () => {
|
||||
// Sequencing: bad opts → good opts. The bad call must throw cleanly; the
|
||||
// good call must still produce a working hooks object. Confirms no
|
||||
// half-built module-level state survives a failed parse.
|
||||
await assert.rejects(
|
||||
() => OmniRoutePlugin(fakeInput, { providerId: "bad id!" } as never),
|
||||
/providerId/
|
||||
);
|
||||
const ok = await OmniRoutePlugin(fakeInput, {
|
||||
providerId: "recovered",
|
||||
baseURL: "https://ok.example/v1",
|
||||
});
|
||||
assert.equal(ok.auth?.provider, "recovered");
|
||||
});
|
||||
@@ -1,104 +0,0 @@
|
||||
/**
|
||||
* T-08 options-schema tests.
|
||||
*
|
||||
* Covers `parseOmniRoutePluginOptions(opts)` — the strict Zod gate that
|
||||
* validates the second-arg `PluginOptions` bag from opencode.json before
|
||||
* any hook is wired. Anti-pattern checklist mirrored here:
|
||||
*
|
||||
* - `null` / `undefined` must collapse to `{}` (defaults apply downstream).
|
||||
* - Unknown keys must THROW (`.strict()` catches opencode.json typos).
|
||||
* - Validation runs at parse time, not import time (module loads cleanly).
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseOmniRoutePluginOptions } from "../src/index.js";
|
||||
|
||||
test("parseOmniRoutePluginOptions: undefined → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions(undefined), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: null → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions(null), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty object → {}", () => {
|
||||
assert.deepEqual(parseOmniRoutePluginOptions({}), {});
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: valid providerId → returns it", () => {
|
||||
const r = parseOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: invalid providerId (special chars) → throws", () => {
|
||||
assert.throws(
|
||||
() => parseOmniRoutePluginOptions({ providerId: "omniroute prod!" }),
|
||||
/providerId.*slug/i
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: empty providerId → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ providerId: "" }), /providerId/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: valid modelCacheTtl → returns it", () => {
|
||||
const r = parseOmniRoutePluginOptions({ modelCacheTtl: 60_000 });
|
||||
assert.equal(r.modelCacheTtl, 60_000);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: negative modelCacheTtl → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: -1 }), /modelCacheTtl/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: zero modelCacheTtl → throws (positive required)", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ modelCacheTtl: 0 }), /modelCacheTtl/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: invalid baseURL (not a URL) → throws", () => {
|
||||
assert.throws(() => parseOmniRoutePluginOptions({ baseURL: "not-a-url" }), /baseURL/i);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: unknown key → throws (strict mode catches typos)", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
parseOmniRoutePluginOptions({
|
||||
providerId: "omniroute",
|
||||
provider_id: "typo-here",
|
||||
}),
|
||||
/provider_id|unrecognized/i
|
||||
);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: all four fields populated correctly → returns them", () => {
|
||||
const opts = {
|
||||
providerId: "omniroute-prod",
|
||||
displayName: "OmniRoute Production",
|
||||
modelCacheTtl: 120_000,
|
||||
baseURL: "https://or.example.com/v1",
|
||||
};
|
||||
const r = parseOmniRoutePluginOptions(opts);
|
||||
assert.deepEqual(r, opts);
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: error message lists every issue path", () => {
|
||||
// Two bad fields at once → error string should mention BOTH.
|
||||
try {
|
||||
parseOmniRoutePluginOptions({
|
||||
providerId: "",
|
||||
baseURL: "garbage",
|
||||
});
|
||||
assert.fail("expected throw");
|
||||
} catch (err) {
|
||||
const msg = (err as Error).message;
|
||||
assert.match(msg, /providerId/);
|
||||
assert.match(msg, /baseURL/);
|
||||
}
|
||||
});
|
||||
|
||||
test("parseOmniRoutePluginOptions: module import alone does NOT throw", async () => {
|
||||
// Re-importing the entry must not trigger validation; validation only fires
|
||||
// on explicit parseOmniRoutePluginOptions / OmniRoutePlugin invocation.
|
||||
const mod = await import("../src/index.js");
|
||||
assert.equal(typeof mod.parseOmniRoutePluginOptions, "function");
|
||||
});
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* T-03 provider-hook contract tests.
|
||||
*
|
||||
* Covers `createOmniRouteProviderHook(opts, deps)`:
|
||||
* - hook.id binds to resolved providerId (single + multi-instance)
|
||||
* - models() narrows ctx.auth, fetches via injected fetcher, caches per
|
||||
* (baseURL, apiKey) tuple, refetches after TTL
|
||||
* - mapRawModelToModelV2 emits a v2 Model shape matching the
|
||||
* @opencode-ai/sdk/v2 type
|
||||
*
|
||||
* Mocking strategy: the fetcher is dependency-injected at hook construction
|
||||
* (`deps.fetcher`). No global fetch monkey-patch needed. `deps.now` lets us
|
||||
* fast-forward time deterministically for TTL assertions.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
type OmniRouteRawModelEntry,
|
||||
type OmniRouteModelsFetcher,
|
||||
} from "../src/index.js";
|
||||
|
||||
const FIXTURE: OmniRouteRawModelEntry[] = [
|
||||
{
|
||||
id: "claude-primary",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: true },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "claude-low",
|
||||
object: "model",
|
||||
owned_by: "combo",
|
||||
capabilities: { tool_calling: true, reasoning: true, vision: true, thinking: false },
|
||||
context_length: 200000,
|
||||
max_output_tokens: 64000,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
{
|
||||
id: "gemini-3-flash",
|
||||
object: "model",
|
||||
owned_by: "google",
|
||||
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 8192,
|
||||
input_modalities: ["text", "image"],
|
||||
output_modalities: ["text"],
|
||||
},
|
||||
];
|
||||
|
||||
function stubFetcher(payload: OmniRouteRawModelEntry[]): OmniRouteModelsFetcher & {
|
||||
callCount: () => number;
|
||||
callsBy: () => Array<[string, string]>;
|
||||
} {
|
||||
let calls: Array<[string, string]> = [];
|
||||
const f: OmniRouteModelsFetcher = async (baseURL, apiKey) => {
|
||||
calls.push([baseURL, apiKey]);
|
||||
return payload;
|
||||
};
|
||||
return Object.assign(f, {
|
||||
callCount: () => calls.length,
|
||||
callsBy: () => calls,
|
||||
});
|
||||
}
|
||||
|
||||
const apiAuth = (key: string, baseURL?: string): unknown =>
|
||||
baseURL ? { type: "api", key, baseURL } : { type: "api", key };
|
||||
|
||||
test("createOmniRouteProviderHook: default providerId is 'omniroute'", () => {
|
||||
const hook = createOmniRouteProviderHook(undefined, { combosFetcher: async () => [] });
|
||||
assert.equal(hook.id, "omniroute");
|
||||
});
|
||||
|
||||
test("createOmniRouteProviderHook: custom providerId binds to hook.id (multi-instance)", () => {
|
||||
const a = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-preprod" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
const b = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute-local" },
|
||||
{ combosFetcher: async () => [] }
|
||||
);
|
||||
assert.equal(a.id, "omniroute-preprod");
|
||||
assert.equal(b.id, "omniroute-local");
|
||||
});
|
||||
|
||||
test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
assert.ok(out["claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
assert.deepEqual(await hook.models!({} as never, {} as never), {});
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: undefined } as never), {});
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, {
|
||||
auth: { type: "oauth", refresh: "r", access: "a", expires: 0 } as never,
|
||||
}),
|
||||
{}
|
||||
);
|
||||
assert.deepEqual(
|
||||
await hook.models!({} as never, { auth: { type: "api", key: "" } as never }),
|
||||
{}
|
||||
);
|
||||
assert.equal(fetcher.callCount(), 0, "fetcher must not be called on auth rejection");
|
||||
});
|
||||
|
||||
test("models: returns {} when no baseURL resolvable (no opts.baseURL and no auth.baseURL)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
// valid api auth but neither opts nor auth carries a baseURL
|
||||
assert.deepEqual(await hook.models!({} as never, { auth: apiAuth("sk-x") as never }), {});
|
||||
assert.equal(fetcher.callCount(), 0);
|
||||
});
|
||||
|
||||
test("models: baseURL falls back to auth.baseURL when opts.baseURL absent", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook({}, { fetcher, combosFetcher: async () => [] });
|
||||
const out = await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-y", "https://or.creds-attached.example/v1") as never,
|
||||
});
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.equal(fetcher.callsBy()[0][0], "https://or.creds-attached.example/v1");
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
});
|
||||
|
||||
test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
const claude = out["claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
assert.equal(claude.id, "claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
// capabilities: toolcall (one word), reasoning OR thinking, attachment = vision
|
||||
assert.equal(claude.capabilities.toolcall, true);
|
||||
assert.equal(claude.capabilities.reasoning, true);
|
||||
assert.equal(claude.capabilities.attachment, true);
|
||||
assert.equal(claude.capabilities.temperature, true);
|
||||
// modalities mapped from arrays
|
||||
assert.equal(claude.capabilities.input.text, true);
|
||||
assert.equal(claude.capabilities.input.image, true);
|
||||
assert.equal(claude.capabilities.input.audio, false);
|
||||
assert.equal(claude.capabilities.output.text, true);
|
||||
assert.equal(claude.capabilities.output.image, false);
|
||||
// cost is zeroed (OmniRoute /v1/models has no pricing)
|
||||
assert.deepEqual(claude.cost, { input: 0, output: 0, cache: { read: 0, write: 0 } });
|
||||
// limits
|
||||
assert.equal(claude.limit.context, 200000);
|
||||
assert.equal(claude.limit.output, 64000);
|
||||
assert.equal(claude.status, "active");
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: thinking-only model still surfaces reasoning=true", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{
|
||||
id: "thinking-only",
|
||||
capabilities: { thinking: true, reasoning: false },
|
||||
context_length: 100000,
|
||||
max_output_tokens: 8192,
|
||||
},
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.reasoning, true);
|
||||
});
|
||||
|
||||
test("mapRawModelToModelV2: missing capabilities defaults to all-false (except temperature)", () => {
|
||||
const m = mapRawModelToModelV2(
|
||||
{ id: "minimal" },
|
||||
{ providerId: "omniroute", baseURL: "https://or.example.com/v1" }
|
||||
);
|
||||
assert.equal(m.capabilities.temperature, true);
|
||||
assert.equal(m.capabilities.reasoning, false);
|
||||
assert.equal(m.capabilities.attachment, false);
|
||||
assert.equal(m.capabilities.toolcall, false);
|
||||
// default modalities = text only
|
||||
assert.equal(m.capabilities.input.text, true);
|
||||
assert.equal(m.capabilities.output.text, true);
|
||||
// missing context / output tokens → 0 fallback (ModelV2.limit.{context,output} required)
|
||||
assert.equal(m.limit.context, 0);
|
||||
assert.equal(m.limit.output, 0);
|
||||
});
|
||||
|
||||
test("models: caches result for second call within TTL (fetcher called once)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
const a = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 30_000; // half the TTL
|
||||
const b = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 1, "second call within TTL must hit the cache");
|
||||
assert.equal(Object.keys(a).length, 3);
|
||||
assert.equal(Object.keys(b).length, 3);
|
||||
});
|
||||
|
||||
test("models: refetches after TTL expires", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
let nowMs = 1_000_000;
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 60_000 },
|
||||
{ fetcher, now: () => nowMs, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
nowMs += 60_001; // just past the TTL
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(fetcher.callCount(), 2, "call past TTL must refetch");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different keys → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1", modelCacheTtl: 300_000 },
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never });
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-A") as never }); // cached
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-B") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "one fetch per distinct apiKey, then cache hits");
|
||||
});
|
||||
|
||||
test("models: caches per (baseURL, apiKey) tuple (different baseURL → independent fetches)", async () => {
|
||||
const fetcher = stubFetcher(FIXTURE);
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ modelCacheTtl: 300_000 }, // no opts.baseURL → falls back to auth.baseURL
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never });
|
||||
await hook.models!({} as never, {
|
||||
auth: apiAuth("sk-same", "https://preprod.example/v1") as never,
|
||||
});
|
||||
await hook.models!({} as never, { auth: apiAuth("sk-same", "https://prod.example/v1") as never }); // cached
|
||||
assert.equal(fetcher.callCount(), 2, "distinct baseURLs share apiKey but not cache");
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createRequire } from "node:module";
|
||||
import {
|
||||
OmniRoutePlugin,
|
||||
OMNIROUTE_PROVIDER_KEY,
|
||||
DEFAULT_MODEL_CACHE_TTL_MS,
|
||||
resolveOmniRoutePluginOptions,
|
||||
} from "../src/index.js";
|
||||
|
||||
test("scaffold: exports public surface", () => {
|
||||
assert.equal(
|
||||
typeof OmniRoutePlugin,
|
||||
"function",
|
||||
"OmniRoutePlugin must be a function (Plugin factory)"
|
||||
);
|
||||
assert.equal(OMNIROUTE_PROVIDER_KEY, "omniroute");
|
||||
assert.equal(DEFAULT_MODEL_CACHE_TTL_MS, 300_000);
|
||||
});
|
||||
|
||||
test("scaffold: default export is v1 plugin shape { id, server: OmniRoutePlugin }", async () => {
|
||||
const mod = await import("../src/index.js");
|
||||
assert.equal(typeof mod.default, "object");
|
||||
assert.equal(mod.default.id, "@omniroute/opencode-plugin");
|
||||
assert.equal(mod.default.server, mod.OmniRoutePlugin);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: defaults", () => {
|
||||
const r = resolveOmniRoutePluginOptions();
|
||||
assert.equal(r.providerId, "omniroute");
|
||||
assert.equal(r.displayName, "OmniRoute");
|
||||
assert.equal(r.modelCacheTtl, 300_000);
|
||||
assert.equal(r.baseURL, undefined);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: custom providerId derives displayName", () => {
|
||||
const r = resolveOmniRoutePluginOptions({ providerId: "omniroute-preprod" });
|
||||
assert.equal(r.providerId, "omniroute-preprod");
|
||||
assert.equal(r.displayName, "OmniRoute (omniroute-preprod)");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: explicit displayName wins", () => {
|
||||
const r = resolveOmniRoutePluginOptions({
|
||||
providerId: "omniroute-x",
|
||||
displayName: "Custom Label",
|
||||
});
|
||||
assert.equal(r.displayName, "Custom Label");
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: invalid TTL falls back to default", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 0 }).modelCacheTtl, 300_000);
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: -1 }).modelCacheTtl, 300_000);
|
||||
});
|
||||
|
||||
test("resolveOmniRoutePluginOptions: positive TTL respected", () => {
|
||||
assert.equal(resolveOmniRoutePluginOptions({ modelCacheTtl: 60_000 }).modelCacheTtl, 60_000);
|
||||
});
|
||||
|
||||
test("OmniRoutePlugin: returns an empty hooks object (scaffold)", async () => {
|
||||
const fakeCtx = {} as Parameters<typeof OmniRoutePlugin>[0];
|
||||
const hooks = await OmniRoutePlugin(fakeCtx);
|
||||
assert.equal(typeof hooks, "object");
|
||||
assert.notEqual(hooks, null);
|
||||
});
|
||||
|
||||
test("scaffold: CJS default export resolves via require() with v1 shape", () => {
|
||||
const require_ = createRequire(import.meta.url);
|
||||
const cjs = require_("../dist/index.cjs");
|
||||
// after cjsInterop:true, default export is on cjs.default
|
||||
assert.strictEqual(typeof cjs.default, "object");
|
||||
assert.strictEqual(cjs.default.id, "@omniroute/opencode-plugin");
|
||||
assert.strictEqual(typeof cjs.default.server, "function");
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Regression tests for `isUsableCombo` (release/v3.8.2 code review, finding C1).
|
||||
*
|
||||
* The combo member refs returned by `/api/combos` do NOT carry a separate
|
||||
* `providerId` field — OmniRoute's `normalizeComboRecord` folds the provider
|
||||
* id INTO the full model string (e.g. "cc/claude-opus-4-7"). The previous
|
||||
* implementation read `step.providerId` (always `undefined`), so the
|
||||
* `usableOnly` combo filter silently never dropped anything. These tests pin
|
||||
* the corrected behavior: the verdict is derived from the `step.model` prefix,
|
||||
* mirroring `isUsableRawModelId`'s subtract-filter semantics.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { isUsableCombo, type OmniRouteRawCombo } from "../src/index.js";
|
||||
|
||||
/** Build a `usable` set bundle for the tests. */
|
||||
function buildUsable(opts: { aliases?: string[]; canonicals?: string[]; known?: string[] }): {
|
||||
aliases: Set<string>;
|
||||
canonicals: Set<string>;
|
||||
knownAliases: Set<string>;
|
||||
} {
|
||||
return {
|
||||
aliases: new Set(opts.aliases ?? []),
|
||||
canonicals: new Set(opts.canonicals ?? []),
|
||||
// knownAliases is the union of every prefix the universe is aware of —
|
||||
// usable or not. Default to including the usable aliases too.
|
||||
knownAliases: new Set([...(opts.known ?? []), ...(opts.aliases ?? [])]),
|
||||
};
|
||||
}
|
||||
|
||||
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
|
||||
return { id: "c1", name: "Test Combo", models };
|
||||
}
|
||||
|
||||
test("isUsableCombo: member with a usable alias prefix → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: all members known-but-NOT-usable → drop (the C1 regression)", () => {
|
||||
// Before the fix this returned true unconditionally because step.providerId
|
||||
// was always undefined. Now the known-but-unusable "dead" prefix is dropped.
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([
|
||||
{ kind: "model", model: "dead/legacy-model" },
|
||||
{ kind: "model", model: "dead/another" },
|
||||
]);
|
||||
assert.equal(isUsableCombo(c, usable), false);
|
||||
});
|
||||
|
||||
test("isUsableCombo: unknown prefix → keep (cannot prove unroutable)", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "agentrouter/mystery" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: mixed non-usable + usable member → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([
|
||||
{ kind: "model", model: "dead/legacy" },
|
||||
{ kind: "model", model: "cc/claude-opus-4-7" },
|
||||
]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: zero members → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc"] });
|
||||
assert.equal(isUsableCombo(combo([]), usable), true);
|
||||
assert.equal(isUsableCombo(combo(undefined), usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: only combo-ref steps (no resolvable model) → keep", () => {
|
||||
const usable = buildUsable({ aliases: ["cc"], known: ["cc", "dead"] });
|
||||
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
|
||||
test("isUsableCombo: usable canonical prefix → keep", () => {
|
||||
const usable = buildUsable({ canonicals: ["anthropic"], known: ["anthropic", "dead"] });
|
||||
const c = combo([{ kind: "model", model: "anthropic/claude-opus-4-7" }]);
|
||||
assert.equal(isUsableCombo(c, usable), true);
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": true,
|
||||
"isolatedModules": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules", "tests"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { defineConfig } from "tsup";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts"],
|
||||
format: ["esm", "cjs"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: false,
|
||||
target: "node22",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
cjsInterop: true,
|
||||
// Bundle runtime deps so the .tgz / npm install is self-contained.
|
||||
// `zod` is required at runtime by the options schema and would otherwise
|
||||
// need a peer install when the plugin is loaded directly from a file path
|
||||
// in opencode.jsonc.
|
||||
noExternal: ["zod"],
|
||||
});
|
||||
@@ -1,23 +1,5 @@
|
||||
# @omniroute/opencode-provider
|
||||
|
||||
> ## ⚠️ Deprecated — use [`@omniroute/opencode-plugin`](https://www.npmjs.com/package/@omniroute/opencode-plugin) instead
|
||||
>
|
||||
> This package writes a **static** `provider.omniroute` block to `opencode.json` from a hardcoded default model list, so it **drifts behind your live OmniRoute catalog** — adding a model in OmniRoute won't show up in OpenCode until you re-run the generator, and OpenCode Desktop/Web only surfaces a subset of the static models.
|
||||
>
|
||||
> **`@omniroute/opencode-plugin`** solves this by fetching `GET /v1/models` from your OmniRoute instance at OpenCode startup, so the model list is always live (see [#3419](https://github.com/diegosouzapw/OmniRoute/issues/3419)). It is now the recommended path.
|
||||
>
|
||||
> **One-line migration** — replace the static `provider.omniroute` block in `opencode.json` with a single plugin entry:
|
||||
>
|
||||
> ```jsonc
|
||||
> // opencode.json
|
||||
> {
|
||||
> "$schema": "https://opencode.ai/config.json",
|
||||
> "plugin": ["@omniroute/opencode-plugin"]
|
||||
> }
|
||||
> ```
|
||||
>
|
||||
> This package is **not removed** and still works for static/offline config generation, but it is no longer actively recommended and won't track new models automatically.
|
||||
|
||||
Helper for connecting [OpenCode](https://opencode.ai) to a running [OmniRoute](https://github.com/diegosouzapw/OmniRoute) AI gateway.
|
||||
|
||||
The package emits a **schema-valid entry** for `opencode.json` (`https://opencode.ai/config.json`) that delegates the actual runtime to [`@ai-sdk/openai-compatible`](https://www.npmjs.com/package/@ai-sdk/openai-compatible). It does not ship any new HTTP client — OmniRoute already exposes an OpenAI-compatible surface, and OpenCode already speaks it through the AI SDK.
|
||||
@@ -118,7 +100,7 @@ Exported for completeness. Strips trailing `/`, deduplicates a trailing `/v1`, a
|
||||
- `OMNIROUTE_PROVIDER_KEY` — `"omniroute"` (the key used under `provider.*`).
|
||||
- `OMNIROUTE_PROVIDER_NPM` — `"@ai-sdk/openai-compatible"` (the runtime delegate).
|
||||
- `OPENCODE_CONFIG_SCHEMA` — `"https://opencode.ai/config.json"`.
|
||||
- `OMNIROUTE_DEFAULT_OPENCODE_MODELS` — readonly list of default model ids.
|
||||
- `OMNIROUTE_DEFAULT_OPENCODE_MODELS` — readonly list of 4 default model ids.
|
||||
|
||||
## Custom model catalog
|
||||
|
||||
@@ -128,10 +110,10 @@ import { createOmniRouteProvider } from "@omniroute/opencode-provider";
|
||||
createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
models: ["auto", "claude-opus-4-8", "gpt-5.5"],
|
||||
models: ["auto", "claude-opus-4-7", "gpt-5.5"],
|
||||
modelLabels: {
|
||||
auto: "Auto-Combo (recommended)",
|
||||
"claude-opus-4-8": "Claude Opus 4.8",
|
||||
"claude-opus-4-7": "Claude Opus 4.7",
|
||||
"gpt-5.5": "GPT-5.5",
|
||||
},
|
||||
});
|
||||
|
||||
22
@omniroute/opencode-provider/package-lock.json
generated
22
@omniroute/opencode-provider/package-lock.json
generated
@@ -9,13 +9,13 @@
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
"@types/node": "^20.19.41",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
"node": ">=20.20.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -857,9 +857,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
|
||||
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
|
||||
"version": "20.19.41",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz",
|
||||
"integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1464,9 +1464,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.22.3",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz",
|
||||
"integrity": "sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==",
|
||||
"version": "4.22.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.0.tgz",
|
||||
"integrity": "sha512-8ccZMPD69s1AbKXx0C5ddTNZfNjwV04iIKgjZmKfKxMynEtSYcK0Lh7iQFh53fI5Yu4pb9usgAiqyPmEONaALg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-provider",
|
||||
"version": "0.1.0",
|
||||
"description": "DEPRECATED — use @omniroute/opencode-plugin instead (it fetches the live OmniRoute /v1/models catalog at startup, so models never drift). This static-config generator still works but is no longer the recommended path. OpenCode provider helper for the OmniRoute AI Gateway.",
|
||||
"description": "OpenCode provider helper for the OmniRoute AI Gateway. Generates a schema-valid provider entry for opencode.json that delegates the runtime to @ai-sdk/openai-compatible.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
@@ -46,15 +46,15 @@
|
||||
},
|
||||
"homepage": "https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider#readme",
|
||||
"engines": {
|
||||
"node": ">=22.22.3"
|
||||
"node": ">=20.20.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.19",
|
||||
"tsup": "^8.5.1",
|
||||
"tsx": "^4.22.3",
|
||||
"typescript": "^5.9.3"
|
||||
"@types/node": "^20.19.41",
|
||||
"tsup": "^8.5.0",
|
||||
"tsx": "^4.20.0",
|
||||
"typescript": "^5.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ export const OPENCODE_CONFIG_SCHEMA = "https://opencode.ai/config.json" as const
|
||||
* with Claude Code passthrough models (`cc/` prefix).
|
||||
*/
|
||||
export const OMNIROUTE_DEFAULT_OPENCODE_MODELS = [
|
||||
"cc/claude-opus-4-8",
|
||||
"cc/claude-opus-4-7",
|
||||
"cc/claude-sonnet-4-6",
|
||||
"cc/claude-haiku-4-5-20251001",
|
||||
@@ -79,21 +78,6 @@ export interface ModelCapabilities {
|
||||
tool_call?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default per-model context window sizes (tokens) for the curated default catalog.
|
||||
* Matches the context lengths used by OmniRoute's provider registry.
|
||||
*/
|
||||
export const OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS: Record<string, number> = {
|
||||
"cc/claude-opus-4-8": 1_000_000,
|
||||
"cc/claude-opus-4-7": 1_000_000,
|
||||
"cc/claude-sonnet-4-6": 200_000,
|
||||
"cc/claude-haiku-4-5-20251001": 200_000,
|
||||
"claude-opus-4-5-thinking": 200_000,
|
||||
"claude-sonnet-4-5-thinking": 200_000,
|
||||
"gemini-3.1-pro-high": 1_000_000,
|
||||
"gemini-3-flash": 1_000_000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Default per-model capability hints for the curated default catalog.
|
||||
*
|
||||
@@ -102,7 +86,6 @@ export const OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS: Record<string, number> = {
|
||||
* model via `OmniRouteProviderOptions.modelCapabilities`.
|
||||
*/
|
||||
export const OMNIROUTE_DEFAULT_MODEL_CAPABILITIES: Record<string, ModelCapabilities> = {
|
||||
"cc/claude-opus-4-8": { attachment: true, reasoning: true, temperature: true, tool_call: true },
|
||||
"cc/claude-opus-4-7": { attachment: true, reasoning: true, temperature: true, tool_call: true },
|
||||
"cc/claude-sonnet-4-6": { attachment: true, reasoning: true, temperature: true, tool_call: true },
|
||||
"cc/claude-haiku-4-5-20251001": { attachment: true, temperature: true, tool_call: true },
|
||||
@@ -129,8 +112,8 @@ export interface OmniRouteProviderOptions {
|
||||
apiKey: string;
|
||||
/** Override the display name shown in OpenCode. Default: `"OmniRoute"`. */
|
||||
displayName?: string;
|
||||
/** Override the model catalog. Accepts model ids (strings) or live model entries from `fetchLiveModels`. When entries carry a `contextLength`, it is used directly — no hardcoded map needed. */
|
||||
models?: readonly (string | { id: string; contextLength?: number })[];
|
||||
/** Override the model catalog. Defaults to `OMNIROUTE_DEFAULT_OPENCODE_MODELS`. */
|
||||
models?: readonly string[];
|
||||
/** Optional human-readable labels keyed by model id. Overridden by `modelCapabilities[id].label`. */
|
||||
modelLabels?: Record<string, string>;
|
||||
/**
|
||||
@@ -139,12 +122,6 @@ export interface OmniRouteProviderOptions {
|
||||
* for custom ids the override is used verbatim.
|
||||
*/
|
||||
modelCapabilities?: Record<string, ModelCapabilities>;
|
||||
/**
|
||||
* Optional per-model context-length overrides (tokens). Takes precedence
|
||||
* over the static `OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS` map but is
|
||||
* superseded by `contextLength` on live model entries passed via `models`.
|
||||
*/
|
||||
modelContextLengths?: Record<string, string | number>;
|
||||
/**
|
||||
* Primary model for OpenCode (top-level `model` key).
|
||||
* Emitted as `"omniroute/<id>"`. When omitted the key is not written.
|
||||
@@ -164,19 +141,6 @@ export interface OpenCodeModelEntry {
|
||||
reasoning?: boolean;
|
||||
temperature?: boolean;
|
||||
tool_call?: boolean;
|
||||
/**
|
||||
* Context window limit. OpenCode reads this to determine usable context
|
||||
* length for compaction, overflow detection, and router decisions.
|
||||
* Maps to `limit.context` in OpenCode's provider config schema.
|
||||
*/
|
||||
limit?: {
|
||||
/** Maximum context length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */
|
||||
context: number;
|
||||
/** Optional per-request max input tokens. */
|
||||
input?: number;
|
||||
/** Optional max output tokens. */
|
||||
output?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenCodeProviderEntry {
|
||||
@@ -254,12 +218,7 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open
|
||||
const models: Record<string, OpenCodeModelEntry> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const raw of modelList) {
|
||||
const id =
|
||||
typeof raw === "object" && raw !== null && "id" in raw && typeof (raw as any).id === "string"
|
||||
? (raw as { id: string }).id.trim()
|
||||
: typeof raw === "string"
|
||||
? raw.trim()
|
||||
: "";
|
||||
const id = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
const defaults = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id] ?? {};
|
||||
@@ -276,22 +235,6 @@ export function createOmniRouteProvider(options: OmniRouteProviderOptions): Open
|
||||
if (typeof merged.reasoning === "boolean") entry.reasoning = merged.reasoning;
|
||||
if (typeof merged.temperature === "boolean") entry.temperature = merged.temperature;
|
||||
if (typeof merged.tool_call === "boolean") entry.tool_call = merged.tool_call;
|
||||
|
||||
// Context window: live model entry (from API catalog) > modelContextLengths > static defaults
|
||||
const liveContext =
|
||||
typeof raw === "object" && raw !== null
|
||||
? (raw as { contextLength?: number }).contextLength
|
||||
: undefined;
|
||||
const rawContextLength =
|
||||
liveContext ??
|
||||
options.modelContextLengths?.[id] ??
|
||||
OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
|
||||
const contextLength =
|
||||
typeof rawContextLength === "string" ? parseInt(rawContextLength, 10) : rawContextLength;
|
||||
if (typeof contextLength === "number" && !isNaN(contextLength) && contextLength > 0) {
|
||||
entry.limit = { context: contextLength };
|
||||
}
|
||||
|
||||
models[id] = entry;
|
||||
}
|
||||
|
||||
@@ -502,8 +445,6 @@ async function fetchJSON<T>(url: string, apiKey: string, timeoutMs: number): Pro
|
||||
export interface OmniRouteLiveModel {
|
||||
id: string;
|
||||
name: string;
|
||||
/** Context window length in tokens (e.g. 200000 for Claude, 1000000 for Gemini). */
|
||||
contextLength?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -527,7 +468,7 @@ export interface OmniRouteLiveModel {
|
||||
* const config = buildOmniRouteOpenCodeConfig({
|
||||
* baseURL: "http://localhost:20128",
|
||||
* apiKey: "sk_omniroute",
|
||||
* models, // OmniRouteLiveModel[] — contextLength auto-extracted
|
||||
* models: models.map((m) => m.id),
|
||||
* modelLabels: Object.fromEntries(models.map((m) => [m.id, m.name])),
|
||||
* });
|
||||
* ```
|
||||
@@ -573,18 +514,7 @@ export async function fetchLiveModels(
|
||||
? r.display_name.trim()
|
||||
: id;
|
||||
|
||||
// Extract context_length from OmniRoute's /v1/models response.
|
||||
// OmniRoute returns context_length in snake_case for both synced
|
||||
// models (with inputTokenLimit) and custom models; the catalog's
|
||||
// getDefaultContextFallback also injects it from registry defaults.
|
||||
const contextLength =
|
||||
typeof r.context_length === "number" && r.context_length > 0
|
||||
? r.context_length
|
||||
: typeof r.max_context_window_tokens === "number" && r.max_context_window_tokens > 0
|
||||
? r.max_context_window_tokens
|
||||
: undefined;
|
||||
|
||||
models.push({ id, name: name || id, ...(contextLength ? { contextLength } : {}) });
|
||||
models.push({ id, name: name || id });
|
||||
}
|
||||
|
||||
return models;
|
||||
|
||||
@@ -2,9 +2,6 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import type { Server } from "node:http";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
buildOmniRouteOpenCodeConfig,
|
||||
@@ -18,7 +15,6 @@ import {
|
||||
mergeIntoExistingConfig,
|
||||
normalizeBaseURL,
|
||||
OMNIROUTE_DEFAULT_MODEL_CAPABILITIES,
|
||||
OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS,
|
||||
OMNIROUTE_DEFAULT_OPENCODE_MODELS,
|
||||
OMNIROUTE_MCP_DEFAULT_SCOPES,
|
||||
OMNIROUTE_PROVIDER_NPM,
|
||||
@@ -391,7 +387,6 @@ test("createOmniRouteComboConfig includes optional fields when supplied", () =>
|
||||
|
||||
test("OMNIROUTE_DEFAULT_OPENCODE_MODELS includes cc/ prefixed models", () => {
|
||||
const defaults = [...OMNIROUTE_DEFAULT_OPENCODE_MODELS];
|
||||
assert.ok(defaults.includes("cc/claude-opus-4-8"));
|
||||
assert.ok(
|
||||
defaults.some((m) => m.startsWith("cc/")),
|
||||
"should have cc/ prefixed models"
|
||||
@@ -399,106 +394,6 @@ test("OMNIROUTE_DEFAULT_OPENCODE_MODELS includes cc/ prefixed models", () => {
|
||||
assert.ok(defaults.length >= 7, "should have at least 7 models");
|
||||
});
|
||||
|
||||
test("OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS covers every default model id", () => {
|
||||
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
|
||||
const ctx = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
|
||||
assert.ok(
|
||||
typeof ctx === "number" && ctx > 0,
|
||||
`default context_length for ${id} missing — should be a positive number`
|
||||
);
|
||||
// Sanity: context should be at least 8K, at most 2M tokens
|
||||
assert.ok(ctx >= 8_000, `${id} context_length ${ctx} seems too low`);
|
||||
assert.ok(ctx <= 2_000_000, `${id} context_length ${ctx} seems too high`);
|
||||
}
|
||||
});
|
||||
|
||||
test("createOmniRouteProvider emits limit.context on default model entries", () => {
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
});
|
||||
const entry = provider.models["cc/claude-opus-4-8"];
|
||||
assert.ok(entry.limit, "model entry should have a limit field");
|
||||
assert.equal(entry.limit!.context, 1_000_000);
|
||||
assert.equal(provider.models["cc/claude-opus-4-7"].limit!.context, 1_000_000);
|
||||
});
|
||||
|
||||
test("createOmniRouteProvider omits limit.context for unknown model ids", () => {
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
models: ["completely-unknown-model"],
|
||||
});
|
||||
const entry = provider.models["completely-unknown-model"];
|
||||
assert.equal(entry.limit, undefined);
|
||||
});
|
||||
|
||||
test("createOmniRouteProvider reads contextLength from a live model entry for ids absent from the static map", () => {
|
||||
// #3298 regression guard: the static OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS
|
||||
// map only covers the legacy 8 Claude/Gemini ids. Before this change, any
|
||||
// other model got `undefined` context (see the test above, string form) and
|
||||
// OpenCode silently fell back to its 128K internal default. A live model
|
||||
// entry carrying `contextLength` must now surface as `limit.context`.
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
models: [{ id: "completely-unknown-model", contextLength: 262_144 }],
|
||||
});
|
||||
const entry = provider.models["completely-unknown-model"];
|
||||
assert.ok(entry.limit, "a live contextLength should produce a limit field even for ids absent from the static map");
|
||||
assert.equal(entry.limit!.context, 262_144);
|
||||
});
|
||||
|
||||
test("createOmniRouteProvider: a live model contextLength wins over the static default map", () => {
|
||||
// `cc/claude-opus-4-8` has a static default (1_000_000). A live entry carrying
|
||||
// a different contextLength must take precedence (live > modelContextLengths >
|
||||
// static defaults).
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
models: [{ id: "cc/claude-opus-4-8", contextLength: 524_288 }],
|
||||
});
|
||||
assert.equal(provider.models["cc/claude-opus-4-8"].limit!.context, 524_288);
|
||||
});
|
||||
|
||||
test("createOmniRouteProvider serialises limit.context to JSON", () => {
|
||||
const provider = createOmniRouteProvider({
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
});
|
||||
const round = JSON.parse(JSON.stringify(provider));
|
||||
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
|
||||
const expectedContext = OMNIROUTE_DEFAULT_MODEL_CONTEXT_LENGTHS[id];
|
||||
assert.equal(
|
||||
round.models[id].limit?.context,
|
||||
expectedContext,
|
||||
`${id} should serialise limit.context=${expectedContext}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("fetchLiveModels extracts context_length from snake_case field", async () => {
|
||||
const { url, close } = await startMockServer(() => ({
|
||||
data: [
|
||||
{ id: "cc/claude-opus-4-7", name: "Claude Opus 4.7", context_length: 200_000 },
|
||||
{ id: "gemini-3.1-pro-high", name: "Gemini 3.1 Pro", context_length: 1_000_000 },
|
||||
{ id: "no-context", name: "No Context" },
|
||||
],
|
||||
}));
|
||||
try {
|
||||
const models = await fetchLiveModels(url, "sk_test");
|
||||
const claude = models.find((m) => m.id === "cc/claude-opus-4-7");
|
||||
assert.ok(claude, "claude model should be present");
|
||||
assert.equal(claude!.contextLength, 200_000);
|
||||
const gemini = models.find((m) => m.id === "gemini-3.1-pro-high");
|
||||
assert.equal(gemini!.contextLength, 1_000_000);
|
||||
const noCtx = models.find((m) => m.id === "no-context");
|
||||
assert.equal(noCtx!.contextLength, undefined);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
test("OMNIROUTE_DEFAULT_MODEL_CAPABILITIES covers every default model id", () => {
|
||||
for (const id of OMNIROUTE_DEFAULT_OPENCODE_MODELS) {
|
||||
const caps = OMNIROUTE_DEFAULT_MODEL_CAPABILITIES[id];
|
||||
@@ -513,8 +408,8 @@ test("createOmniRouteProvider emits default capability flags inline with the mod
|
||||
baseURL: "http://localhost:20128",
|
||||
apiKey: "sk_omniroute",
|
||||
});
|
||||
const entry = provider.models["cc/claude-opus-4-8"];
|
||||
assert.equal(entry.name, "cc/claude-opus-4-8");
|
||||
const entry = provider.models["cc/claude-opus-4-7"];
|
||||
assert.equal(entry.name, "cc/claude-opus-4-7");
|
||||
assert.equal(entry.attachment, true);
|
||||
assert.equal(entry.reasoning, true);
|
||||
assert.equal(entry.temperature, true);
|
||||
@@ -670,17 +565,3 @@ test("createOmniRouteModesBlock honours numeric overrides limited to OC schema",
|
||||
assert.equal(block.build.temperature, 0.7);
|
||||
assert.equal(block.build.top_p, 0.9);
|
||||
});
|
||||
|
||||
// #3419 — soft-deprecation in favour of @omniroute/opencode-plugin. Guard the
|
||||
// deprecation notice so it can't be silently dropped while the package is kept
|
||||
// publishing (it still works; it is just no longer the recommended path).
|
||||
test("package is marked deprecated in favour of @omniroute/opencode-plugin (#3419)", () => {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
||||
assert.match(pkg.description, /DEPRECATED/);
|
||||
assert.match(pkg.description, /@omniroute\/opencode-plugin/);
|
||||
|
||||
const readme = readFileSync(join(here, "..", "README.md"), "utf8");
|
||||
assert.match(readme, /Deprecated/i);
|
||||
assert.match(readme, /@omniroute\/opencode-plugin/);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig({
|
||||
sourcemap: false,
|
||||
splitting: false,
|
||||
treeshake: true,
|
||||
target: "node22",
|
||||
target: "node20",
|
||||
outDir: "dist",
|
||||
minify: false,
|
||||
});
|
||||
|
||||
196
AGENTS.md
196
AGENTS.md
@@ -3,44 +3,10 @@
|
||||
## Project
|
||||
|
||||
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
|
||||
with **229 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
with **160+ providers** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
|
||||
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
|
||||
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
|
||||
with **MCP Server** (69 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
> **Live counts (v3.8.16)**: providers 229 · MCP tools 69 · MCP scopes 13 · A2A skills 6 ·
|
||||
> open-sse services 111 · routing strategies 15 · auto-combo scoring factors 12 ·
|
||||
> DB modules 76 · DB migrations 94 · base tables 17 · search providers 12 ·
|
||||
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
|
||||
|
||||
## Doc Accuracy Discipline (read before writing any doc)
|
||||
|
||||
> **If `grep -rn "name" src/ open-sse/ bin/` returns nothing, the name does not exist. Do not document it.**
|
||||
|
||||
The recurring failure mode in AI-generated docs is _plausible-but-unverified specifics_.
|
||||
Every claim in a `.md` file under `docs/` should be verifiable against the source.
|
||||
|
||||
**Rules (enforced by `npm run check:fabricated-docs`):**
|
||||
|
||||
1. **Never state an API name, endpoint, path, CLI command, or env var without grepping for it first.**
|
||||
```bash
|
||||
grep -rn "theName" src/ open-sse/ bin/
|
||||
# 0 hits → do not document
|
||||
```
|
||||
2. **Never write a line count, file size, migration count, provider count, or strategy count from memory.**
|
||||
```bash
|
||||
wc -l <file> # exact line count
|
||||
ls <dir>/*.ts | wc -l # file count
|
||||
```
|
||||
3. **Every code example should be copy-pasted from real usage or actually run** — not synthesized.
|
||||
Link to a real call site (`path:line`) instead of inventing a signature.
|
||||
4. **Prefer citing real source (`file.ts:line`) over paraphrasing behavior** — verifiable and self-correcting.
|
||||
5. **A shorter doc that is 100% accurate beats a comprehensive one with fabrications.**
|
||||
Wrong docs cost more than missing docs, because people trust and act on them.
|
||||
|
||||
The script `scripts/check/check-fabricated-docs.mjs` extracts every route path, env var, hook
|
||||
name, function name, and file reference from `docs/**/*.md` and verifies each one against the
|
||||
codebase. Run it locally before pushing docs; it runs in CI via `npm run check:docs-all`.
|
||||
with **MCP Server** (37 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -49,7 +15,7 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d
|
||||
- **Database**: better-sqlite3 (SQLite) — `DATA_DIR` configurable, default `~/.omniroute/`
|
||||
- **Streaming**: SSE via `open-sse` internal workspace package
|
||||
- **Styling**: Tailwind CSS v4
|
||||
- **i18n**: next-intl with 42 locales (`src/i18n/messages/`) — refresh with `ls src/i18n/messages/*.json | wc -l`
|
||||
- **i18n**: next-intl with 40+ languages
|
||||
- **Desktop**: Electron (cross-platform: Windows, macOS, Linux)
|
||||
- **Schemas**: Zod v4 for all API / MCP input validation
|
||||
|
||||
@@ -57,32 +23,19 @@ codebase. Run it locally before pushing docs; it runs in CI via `npm run check:d
|
||||
|
||||
## Build, Lint, and Test Commands
|
||||
|
||||
| Command | Description |
|
||||
| ----------------------------------- | ------------------------------------------------------------------ |
|
||||
| `npm run dev` | Start Next.js dev server |
|
||||
| `npm run build` | Production build: `next build` → `.build/next/` + assemble `dist/` |
|
||||
| `npm run build:release` | Clean rebuild + HEAD sentinel (`dist/BUILD_SHA`) — use for deploy |
|
||||
| `npm run start` | Run production build |
|
||||
| `npm run build:cli` | Build CLI package |
|
||||
| `npm run lint` | ESLint on all source files |
|
||||
| `npm run typecheck:core` | TypeScript core type checking |
|
||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
||||
| `npm run check` | Run lint + test |
|
||||
| `npm run check:cycles` | Check for circular dependencies |
|
||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
||||
| `npm run electron:build` | Build Electron app for current OS |
|
||||
|
||||
**Build output layout:**
|
||||
|
||||
| Directory | Purpose | Gitignored |
|
||||
| --------- | -------------------------------------------------- | ---------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | No |
|
||||
| `.build/` | Build intermediates (`distDir = .build/next`) | Yes |
|
||||
| `dist/` | Shippable bundle assembled by `assembleStandalone` | Yes |
|
||||
|
||||
The pipeline is a single `next build` pass — intermediates land in `.build/next/`, the
|
||||
assembled bundle in `dist/`. VPS deploys rsync `dist/` into the remote
|
||||
`/usr/lib/node_modules/omniroute/app/` directory (VPS image path is unchanged).
|
||||
| Command | Description |
|
||||
| ----------------------------------- | --------------------------------- |
|
||||
| `npm run dev` | Start Next.js dev server |
|
||||
| `npm run build` | Production build (isolated) |
|
||||
| `npm run start` | Run production build |
|
||||
| `npm run build:cli` | Build CLI package |
|
||||
| `npm run lint` | ESLint on all source files |
|
||||
| `npm run typecheck:core` | TypeScript core type checking |
|
||||
| `npm run typecheck:noimplicit:core` | Strict checking (no implicit any) |
|
||||
| `npm run check` | Run lint + test |
|
||||
| `npm run check:cycles` | Check for circular dependencies |
|
||||
| `npm run electron:dev` | Run Electron app in dev mode |
|
||||
| `npm run electron:build` | Build Electron app for current OS |
|
||||
|
||||
### Running Tests
|
||||
|
||||
@@ -178,7 +131,7 @@ Always run `prettier --write` on changed files.
|
||||
|
||||
### Data Layer (`src/lib/db/`)
|
||||
|
||||
All persistence uses SQLite through **76 domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
All persistence uses SQLite through **45+ domain-specific modules** in `src/lib/db/`. Top modules:
|
||||
|
||||
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
|
||||
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
|
||||
@@ -188,17 +141,17 @@ All persistence uses SQLite through **76 domain-specific modules** in `src/lib/d
|
||||
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
|
||||
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
|
||||
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 76). Drift detection: `npm run check:docs-counts`.
|
||||
Schema migrations live in `db/migrations/` (**94 files** as of v3.8.16) and run via `migrationRunner.ts`.
|
||||
Live count: `ls src/lib/db/*.ts | wc -l` (currently 45).
|
||||
Schema migrations live in `db/migrations/` (55 files) and run via `migrationRunner.ts`.
|
||||
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
|
||||
|
||||
#### DB Internals
|
||||
|
||||
- **`core.ts`**: `getDbInstance()` returns a singleton `better-sqlite3` instance with WAL
|
||||
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
journaling. `SCHEMA_SQL` defines 15 base tables. Helpers: `rowToCamel`, `encryptConnectionFields`.
|
||||
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
|
||||
Tracks applied migrations in `_omniroute_migrations` table.
|
||||
- **Migrations**: 94 files (`001_initial_schema.sql` → `094_*.sql`).
|
||||
- **Migrations**: 55 files (`001_initial_schema.sql` → `055_command_code_auth_sessions.sql`).
|
||||
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
|
||||
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
|
||||
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
|
||||
@@ -214,19 +167,19 @@ Route → CORS preflight → Body validation (Zod) → Optional auth (extractApi
|
||||
→ API key policy enforcement (enforceApiKeyPolicy) → Handler delegation (open-sse)
|
||||
```
|
||||
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (12 providers per `open-sse/handlers/search.ts:6`) |
|
||||
| Route | Handler | Notes |
|
||||
| ------------------------------- | ------------------------- | ----------------------------------------- |
|
||||
| `chat/completions/route.ts` | `handleChat()` | + prompt injection guard (clones request) |
|
||||
| `responses/route.ts` | `handleChat()` (unified) | Responses API format |
|
||||
| `embeddings/route.ts` | `handleEmbedding()` | Model listing + creation |
|
||||
| `images/generations/route.ts` | `handleImageGeneration()` | Model listing + creation |
|
||||
| `audio/transcriptions/route.ts` | audio handler | Multipart form data |
|
||||
| `audio/speech/route.ts` | TTS handler | Binary audio response |
|
||||
| `videos/generations/route.ts` | video handler | ComfyUI/SD WebUI |
|
||||
| `music/generations/route.ts` | music handler | ComfyUI workflows |
|
||||
| `moderations/route.ts` | moderation handler | Content safety |
|
||||
| `rerank/route.ts` | rerank handler | Document relevance |
|
||||
| `search/route.ts` | search handler | Web search (5 providers) |
|
||||
|
||||
**No global Next.js middleware file** — interception is route-specific. Auth is optional
|
||||
(controlled by `REQUIRE_API_KEY` env). Prompt injection guard is unique to chat completions.
|
||||
@@ -336,8 +289,7 @@ Includes request/response translators with helpers for image handling.
|
||||
|
||||
### Services (`open-sse/services/`)
|
||||
|
||||
111 service modules in `open-sse/services/` (top-level only; 171 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
|
||||
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
36+ service modules including: `combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
|
||||
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
|
||||
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
|
||||
`contextManager.ts`, `modelDeprecation.ts`, `modelFamilyFallback.ts`,
|
||||
@@ -378,8 +330,8 @@ Modular prompt compression that runs proactively before the existing reactive co
|
||||
and iterates through targets in order until one succeeds or all fail.
|
||||
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
|
||||
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
|
||||
- **Strategies** (15): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
|
||||
- **Strategies** (14): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
|
||||
cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay.
|
||||
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
|
||||
per-target error handling and circuit breaker checks.
|
||||
|
||||
@@ -391,7 +343,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
|
||||
|
||||
### MCP Server (`open-sse/mcp-server/`)
|
||||
|
||||
69 tools across 10 tool files (advancedTools: 5, agentSkillTools: 3, compressionTools: 5, gamificationTools: 8, memoryTools: 3, notionTools: 6, obsidianTools: 22, pluginTools: 8, poolTools: 5, skillTools: 4), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (13 scopes — see `OMNIROUTE_MCP_SCOPES` in `open-sse/mcp-server/README.md:51`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
37 tools (30 base + 3 memory + 4 skills), 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (~13 scopes), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
|
||||
|
||||
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
|
||||
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
|
||||
@@ -417,7 +369,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires
|
||||
`createMcpServer()` wires all tool sets; `startMcpStdio()` launches the stdio transport.
|
||||
- **Transports**: stdio (CLI `omniroute --mcp`), SSE (`/api/mcp/sse`), Streamable HTTP
|
||||
(`/api/mcp/stream`). All share the same tool/scope engine.
|
||||
- **Scopes** (13): Control which tool categories an API key can access. Enforcement happens
|
||||
- **Scopes** (10): Control which tool categories an API key can access. Enforcement happens
|
||||
before handler dispatch.
|
||||
- **Audit**: Every tool invocation is logged to SQLite (`mcp_audit` table) with tool name,
|
||||
args, success/failure, API key attribution, and timestamp.
|
||||
@@ -426,7 +378,7 @@ handler: async (args) => {...} }`. Zod validates inputs before the handler fires
|
||||
|
||||
JSON-RPC 2.0, SSE streaming, Task Manager with TTL cleanup.
|
||||
Agent Card at `/.well-known/agent.json`.
|
||||
Skills (6): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`, `listCapabilities.ts`.
|
||||
Skills (5): `smartRouting.ts`, `quotaManagement.ts`, `providerDiscovery.ts`, `costAnalysis.ts`, `healthReport.ts`.
|
||||
|
||||
#### A2A Internals
|
||||
|
||||
@@ -527,49 +479,31 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
|
||||
|
||||
For any non-trivial change, read the matching deep-dive first:
|
||||
|
||||
| Area | Doc |
|
||||
| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
|
||||
---
|
||||
|
||||
## Fork / Upstream Workflow
|
||||
|
||||
This repository is a fork of `diegosouzapw/OmniRoute`. Keep fork-only operational
|
||||
changes (for example GHCR image publishing, personal deployment workflows, or local
|
||||
automation) out of upstream contribution PRs.
|
||||
|
||||
When preparing a PR for upstream, always start the work branch from `upstream/main`,
|
||||
not from this fork's `main`:
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git switch -c <branch-name> upstream/main
|
||||
```
|
||||
|
||||
Only cherry-pick or reapply the changes intended for the upstream PR.
|
||||
| Area | Doc |
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
|
||||
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
|
||||
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
|
||||
| Auto-Combo (9-factor, 14 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
|
||||
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
|
||||
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
|
||||
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
|
||||
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
|
||||
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
|
||||
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
|
||||
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
|
||||
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
|
||||
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
|
||||
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
|
||||
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
|
||||
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
|
||||
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
|
||||
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
|
||||
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/reference/openapi.yaml`](docs/reference/openapi.yaml) |
|
||||
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
|
||||
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
|
||||
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
|
||||
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
1472
CHANGELOG.md
1472
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
48
CLAUDE.md
48
CLAUDE.md
@@ -11,7 +11,7 @@ npm run build # Production build (Next.js 16 standalone)
|
||||
npm run lint # ESLint (0 errors expected; warnings are pre-existing)
|
||||
npm run typecheck:core # TypeScript check (should be clean)
|
||||
npm run typecheck:noimplicit:core # Strict check (no implicit any)
|
||||
npm run test:coverage # Unit tests + coverage gate (60/60/60/60 — statements/lines/functions/branches)
|
||||
npm run test:coverage # Unit tests + coverage gate (75/75/75/70 — statements/lines/functions/branches)
|
||||
npm run check # lint + test combined
|
||||
npm run check:cycles # Detect circular dependencies
|
||||
```
|
||||
@@ -47,7 +47,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
|
||||
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
|
||||
| Database | `src/lib/db/` | SQLite domain modules (45+ files, 55 migrations) |
|
||||
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
|
||||
| MCP Server | `open-sse/mcp-server/` | 43 tools (30 base + 3 memory + 4 skills + 6 notion), 3 transports, ~13 scopes |
|
||||
| MCP Server | `open-sse/mcp-server/` | 37 tools (30 base + 3 memory + 4 skills), 3 transports, ~13 scopes |
|
||||
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
|
||||
| Skills | `src/lib/skills/` | Extensible skill framework |
|
||||
| Memory | `src/lib/memory/` | Persistent conversational memory |
|
||||
@@ -303,17 +303,6 @@ connection continue serving other models.
|
||||
4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`)
|
||||
5. Tests + document in `docs/frameworks/CLOUD_AGENT.md`
|
||||
|
||||
### Adding a New Embedded Service
|
||||
|
||||
1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13).
|
||||
2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`).
|
||||
3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`).
|
||||
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
|
||||
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
|
||||
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
|
||||
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/reference/openapi.yaml`.
|
||||
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
|
||||
|
||||
### Adding a New Guardrail / Eval / Skill / Webhook event
|
||||
|
||||
- Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md`
|
||||
@@ -352,7 +341,6 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/reference/openapi.yaml` |
|
||||
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
|
||||
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
|
||||
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
|
||||
|
||||
---
|
||||
|
||||
@@ -366,23 +354,14 @@ For any non-trivial change, read the matching deep-dive first:
|
||||
| E2E (Playwright) | `npm run test:e2e` |
|
||||
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
|
||||
| Ecosystem | `npm run test:ecosystem` |
|
||||
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
|
||||
| Coverage gate | `npm run test:coverage` (75/75/75/70 — statements/lines/functions/branches) |
|
||||
| Coverage report | `npm run coverage:report` |
|
||||
|
||||
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
|
||||
|
||||
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
|
||||
|
||||
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
|
||||
|
||||
**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions:
|
||||
1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more.
|
||||
2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior.
|
||||
3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess.
|
||||
|
||||
Why this matters: fixing bug A while opening bug B is worse than not fixing at all. The TDD/VPS gate enforces surgical scope — you touch only what the failing test proves is broken. Examples where this paid off: #3090 (claude-web 403), #3113 (WS HTTP fallback), #3052 (heap-guard auto-calibration).
|
||||
|
||||
**Copilot coverage policy**: When a PR changes production code and coverage is below 60% (statements/lines/functions/branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
|
||||
**Copilot coverage policy**: When a PR changes production code and coverage is below 75% (statements/lines/functions) or 70% (branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
|
||||
|
||||
---
|
||||
|
||||
@@ -428,26 +407,11 @@ git push -u origin feat/your-feature
|
||||
6. Never silently swallow errors in SSE streams
|
||||
7. Always validate inputs with Zod schemas
|
||||
8. Always include tests when changing production code
|
||||
9. Coverage must stay ≥60% (statements, lines, functions, branches).
|
||||
9. Coverage must stay ≥75% (statements, lines, functions) / ≥70% (branches). Current measured: ~82%.
|
||||
10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
|
||||
11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`.
|
||||
12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`.
|
||||
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
|
||||
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
16. Never include `Co-Authored-By` trailers that credit an AI assistant, LLM, or automation account (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses). Such trailers route attribution to the bot account on GitHub, hiding the real author (`diegosouzapw`) in PR history. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
|
||||
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
|
||||
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
|
||||
|
||||
---
|
||||
|
||||
## PII & Stream Sanitization Learnings
|
||||
|
||||
### 1. Regex Security (ReDoS)
|
||||
All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs.
|
||||
|
||||
### 2. SSE Snapshot Handling
|
||||
When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream.
|
||||
|
||||
### 3. Database Handles in Tests
|
||||
Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely.
|
||||
16. Never include `Co-Authored-By` trailers in commit messages. Commits must appear solely under the repository owner's Git identity (`diegosouzapw`). The `Co-Authored-By: Claude …` line causes GitHub to attribute commits to the `claude` Anthropic account, hiding the real author in the PR history.
|
||||
|
||||
@@ -8,7 +8,7 @@ Thank you for your interest in contributing! This guide covers everything you ne
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS)
|
||||
- **Node.js** `>=20.20.2 <21`, `>=22.22.2 <23`, or `>=24.0.0 <25` (recommended: 24 LTS)
|
||||
- **npm** 10+
|
||||
- **Git**
|
||||
|
||||
@@ -59,40 +59,13 @@ These settings are stored in the database and persist across restarts, overridin
|
||||
npm run dev
|
||||
|
||||
# Production build
|
||||
npm run build # next build → .build/next/ then assembleStandalone → dist/
|
||||
npm run build
|
||||
npm run start
|
||||
|
||||
# Release build (clean rebuild + HEAD sentinel — required for deploy)
|
||||
npm run build:release # rm -rf .build dist && build + writes dist/BUILD_SHA
|
||||
|
||||
# Common port configuration
|
||||
PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
|
||||
```
|
||||
|
||||
### Build Output Layout
|
||||
|
||||
| Directory | Contents | Tracked |
|
||||
| ---------- | ------------------------------------------- | ------- |
|
||||
| `src/` | Application source (TypeScript / TSX) | Yes |
|
||||
| `.build/` | Intermediates — `next build` output (gitignored, `distDir = .build/next`) | No |
|
||||
| `dist/` | Shippable bundle — assembled by `assembleStandalone` (gitignored) | No |
|
||||
|
||||
The build pipeline is a single pass:
|
||||
|
||||
```
|
||||
npm run build
|
||||
└─ next build → .build/next/standalone (Next.js output)
|
||||
└─ assembleStandalone() (copies standalone + static + public + native assets)
|
||||
└─ output: dist/ (server.js, .next/static/, public/, node_modules/)
|
||||
```
|
||||
|
||||
`npm run build:release` additionally cleans both directories first and writes
|
||||
`dist/BUILD_SHA` (= `git rev-parse --short HEAD`) as a deploy integrity sentinel.
|
||||
|
||||
> **VPS deploy note:** the remote image directory `/usr/lib/node_modules/omniroute/app/`
|
||||
> is unchanged. The deploy skills rsync the contents of `dist/` into it.
|
||||
> Only the in-repo build output path moved (`app/` → `dist/`).
|
||||
|
||||
Default URLs:
|
||||
|
||||
- **Dashboard**: `http://localhost:20128/dashboard`
|
||||
@@ -259,25 +232,18 @@ tests/
|
||||
├── translator/ # Translator-specific tests
|
||||
└── load/ # Load tests
|
||||
|
||||
docs/
|
||||
├── adr/ # Architecture Decision Records
|
||||
├── architecture/ # System architecture & resilience
|
||||
├── comparison/ # OmniRoute vs alternatives
|
||||
├── compression/ # Compression guides & rules
|
||||
├── dev/ # Development guides
|
||||
├── diagrams/ # Architecture diagrams
|
||||
├── frameworks/ # MCP, A2A, OpenCode, Memory, Skills
|
||||
├── guides/ # User guide, Docker, setup, troubleshooting
|
||||
├── i18n/ # Internationalized README translations
|
||||
├── marketing/ # Marketing materials
|
||||
├── ops/ # Deployment, proxy, coverage, releases
|
||||
├── providers/ # Provider-specific docs
|
||||
├── reference/ # API reference, env vars, CLI tools, free tiers
|
||||
├── releases/ # Release notes
|
||||
├── routing/ # Auto-combo engine, reasoning replay
|
||||
├── screenshots/ # Dashboard screenshots
|
||||
├── security/ # Guardrails, compliance, stealth, tokens
|
||||
└── specs/ # Design specs
|
||||
docs/ # Documentation
|
||||
├── ARCHITECTURE.md # System architecture
|
||||
├── API_REFERENCE.md # All endpoints
|
||||
├── USER_GUIDE.md # Provider setup, CLI integration
|
||||
├── TROUBLESHOOTING.md # Common issues
|
||||
├── MCP-SERVER.md # MCP server (25 tools)
|
||||
├── A2A-SERVER.md # A2A agent protocol
|
||||
├── AUTO-COMBO.md # Auto-combo engine
|
||||
├── CLI-TOOLS.md # CLI tools integration
|
||||
├── COVERAGE_PLAN.md # Test coverage improvement plan
|
||||
├── openapi.yaml # OpenAPI specification
|
||||
└── adr/ # Architecture Decision Records
|
||||
```
|
||||
|
||||
---
|
||||
@@ -341,10 +307,6 @@ Write unit tests in `tests/unit/` covering at minimum:
|
||||
|
||||
Releases are managed via the `/generate-release` workflow. When a new GitHub Release is created, the package is **automatically published to npm** via GitHub Actions.
|
||||
|
||||
For VPS deploys, use `npm run build:release` (not `npm run build`) — it performs a clean
|
||||
rebuild, assembles the bundle into `dist/`, and writes the `dist/BUILD_SHA` sentinel.
|
||||
Then use the `/deploy-vps-*-cc` skills which rsync `dist/` to the remote `app/` directory.
|
||||
|
||||
---
|
||||
|
||||
## Getting Help
|
||||
|
||||
159
Dockerfile
159
Dockerfile
@@ -1,52 +1,26 @@
|
||||
# ── Common base with runtime deps ──────────────────────────────────────────
|
||||
FROM node:24-trixie-slim AS base
|
||||
FROM node:26.1.0-trixie-slim AS builder
|
||||
WORKDIR /app
|
||||
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY package*.json ./
|
||||
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
|
||||
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
|
||||
ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# --ignore-scripts blocks broad dependency install/postinstall hooks, closing
|
||||
# the supply-chain attack surface where a transitive dep can run arbitrary code
|
||||
# at install time. better-sqlite3 still needs a native binding for the target
|
||||
# platform, so rebuild and smoke-test only that known runtime dependency below.
|
||||
#
|
||||
# We REQUIRE a committed package-lock.json so resolved dependency versions
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# Use Turbopack for significant build speedup
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
RUN if [ -f package-lock.json ]; then \
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps; \
|
||||
else \
|
||||
npm install --no-audit --no-fund --legacy-peer-deps; \
|
||||
fi
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data && npm run build
|
||||
RUN mkdir -p /app/data && npm run build -- --webpack
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
FROM node:26.1.0-trixie-slim AS runner-base
|
||||
WORKDIR /app
|
||||
|
||||
LABEL org.opencontainers.image.title="omniroute" \
|
||||
org.opencontainers.image.description="Unified AI proxy — route any LLM through one endpoint" \
|
||||
@@ -57,113 +31,54 @@ LABEL org.opencontainers.image.title="omniroute" \
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV OMNIROUTE_MEMORY_MB=1024
|
||||
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
ENV NODE_OPTIONS="--max-old-space-size=256"
|
||||
|
||||
# Data directory inside Docker — must match the volume mount in docker-compose.yml
|
||||
ENV DATA_DIR=/app/data
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir -p /app/data
|
||||
|
||||
# `npm run build` (build-next-isolated → assembleStandalone) bundles ALL runtime
|
||||
# files into .build/next/standalone/ — .next, node_modules, migrations, scripts,
|
||||
# docs, and the previously hand-COPY'd modules below (@swc/helpers, pino-*, split2,
|
||||
# migrations). assembleStandalone copies them straight from the builder's
|
||||
# node_modules, so they are present regardless of NFT/Turbopack trace behaviour.
|
||||
# The old per-module overrides were therefore pure duplication and were removed
|
||||
# (build-output-isolation cleanup). See scripts/build/assembleStandalone.mjs
|
||||
# (EXTRA_MODULE_ENTRIES) for the single source of truth.
|
||||
COPY --from=builder /app/.build/next/standalone ./
|
||||
# better-sqlite3 is the one exception still copied explicitly: assembleStandalone
|
||||
# only syncs its native build/ dir; the JS wrapper (lib/, package.json) is left to
|
||||
# Next.js tracing. bootstrap-env requires SQLite BEFORE the standalone server
|
||||
# starts, so guarantee the complete package independent of trace behaviour.
|
||||
COPY --from=builder /app/node_modules/better-sqlite3 ./node_modules/better-sqlite3
|
||||
# migrations land at <standalone>/migrations via assembleStandalone; point the runtime at them.
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
# Explicitly copy @swc/helpers — not always traced by standalone output but needed at runtime
|
||||
COPY --from=builder /app/node_modules/@swc/helpers ./node_modules/@swc/helpers
|
||||
# Explicitly copy pino transport dependencies — pino spawns a worker that requires
|
||||
# pino-abstract-transport at runtime; Next.js standalone trace does not capture it (#449)
|
||||
COPY --from=builder /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||
COPY --from=builder /app/node_modules/pino-pretty ./node_modules/pino-pretty
|
||||
COPY --from=builder /app/node_modules/split2 ./node_modules/split2
|
||||
# Migration SQL files are read via fs.readFileSync at runtime and are NOT
|
||||
# traced by Next.js standalone output — copy them explicitly.
|
||||
COPY --from=builder /app/src/lib/db/migrations ./migrations
|
||||
ENV OMNIROUTE_MIGRATIONS_DIR=/app/migrations
|
||||
# MITM server.cjs is spawned at runtime via child_process — not traced by nft
|
||||
COPY --from=builder /app/src/mitm/server.cjs ./src/mitm/server.cjs
|
||||
# Documentation files and OpenAPI spec are read from disk at runtime.
|
||||
# Next.js standalone tracing does not include them.
|
||||
COPY --from=builder /app/docs ./docs
|
||||
|
||||
# Docker healthcheck script — not traced by Next.js standalone output, so copy
|
||||
# it explicitly. The HEALTHCHECK CMD references it as `node healthcheck.mjs`.
|
||||
COPY --from=builder /app/scripts/dev/run-standalone.mjs ./dev/run-standalone.mjs
|
||||
COPY --from=builder /app/scripts/build/runtime-env.mjs ./build/runtime-env.mjs
|
||||
COPY --from=builder /app/scripts/build/bootstrap-env.mjs ./build/bootstrap-env.mjs
|
||||
COPY --from=builder /app/scripts/dev/healthcheck.mjs ./healthcheck.mjs
|
||||
|
||||
# Hand /app over to the baked-in `node` non-root user (UID/GID 1000) so the
|
||||
# runtime process never holds root privileges. The chown happens after all
|
||||
# COPYs so it covers files originally owned by root in the builder stage.
|
||||
RUN chown -R node:node /app
|
||||
|
||||
EXPOSE 20128
|
||||
|
||||
# Drop to non-root before ENTRYPOINT/CMD so every derived stage (runner-cli,
|
||||
# runner-web) also runs as a non-root user unless they explicitly switch back.
|
||||
USER node
|
||||
|
||||
# Warns if the mounted data volume has wrong ownership
|
||||
COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
|
||||
ENTRYPOINT ["/tmp/check-permissions.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD ["node", "healthcheck.mjs"]
|
||||
|
||||
CMD ["node", "dev/run-standalone.mjs"]
|
||||
|
||||
# ── Runner Web (web-cookie providers: Gemini Web, Claude Turnstile) ───────────
|
||||
#
|
||||
# Two image flavors:
|
||||
# runner-base → omniroute:VERSION Lean base (~500 MB). No browsers.
|
||||
# runner-web → omniroute:VERSION-web +Chromium/Playwright (~800 MB).
|
||||
#
|
||||
# Use runner-web when you need web-cookie providers (gemini-web, claude-web,
|
||||
# claude-turnstile). For all other providers runner-base is sufficient.
|
||||
#
|
||||
# Build:
|
||||
# docker build --target runner-web -t omniroute:web .
|
||||
# Compose:
|
||||
# build:
|
||||
# context: .
|
||||
# target: runner-web
|
||||
FROM runner-base AS runner-web
|
||||
|
||||
USER root
|
||||
|
||||
# Copy playwright and playwright-core from the builder stage.
|
||||
# The slim runtime image does not have playwright in node_modules, so npx falls
|
||||
# back to a registry download — unreliable on CI runners (exits 127 on failure).
|
||||
# Copying from the builder avoids any network access at image-build time and also
|
||||
# ensures the same playwright version is available at runtime for web-session providers.
|
||||
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
|
||||
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
|
||||
# Install Playwright browser binaries + OS dependencies under root, then hand
|
||||
# ownership of the browsers cache to the node user.
|
||||
# PLAYWRIGHT_BROWSERS_PATH overrides the default ~/.cache/ms-playwright so the
|
||||
# browsers land under /home/node which persists across image layers and is
|
||||
# accessible to the non-root runtime user.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& node node_modules/playwright/cli.js install chromium --with-deps \
|
||||
&& chown -R node:node /home/node/.cache \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
USER node
|
||||
|
||||
FROM runner-base AS runner-cli
|
||||
|
||||
# Drop back to root briefly so we can install system + global npm packages,
|
||||
# then return to the `node` non-root user before the CMD inherited from
|
||||
# runner-base runs.
|
||||
USER root
|
||||
|
||||
# Install system dependencies required by openclaw (git+ssh references).
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
# Fase 6A — Auditoria Crítica das Fases 0–6: o que deixamos passar
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Tarefas P0/P1 maiores devem ser **expandidas em sub-plano bite-sized próprio** (`writing-plans`) no momento da execução. Hard Rule #18 (TDD/VPS) em tudo. Auditar subagentes (trust-but-verify) após cada task.
|
||||
|
||||
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
|
||||
> Mesma janela da Fase 7 (decisão do owner 2026-06-09: 1 semana de uso em produção das Fases 0–6 antes de evoluir). **Ordem na ativação: Fase 6A ANTES da Fase 7** — primeiro consertamos/endurecemos o que já existe, depois adicionamos ferramentas novas.
|
||||
> **Exceção possível (decisão do owner):** as tasks **6A.1 e 6A.2 são bugs pré-existentes descobertos pela auditoria** (testes que nunca rodam + suíte vitest fora do CI), não "gates novos" — podem ser antecipados como fix avulso se o owner preferir não esperar a janela.
|
||||
|
||||
**Goal:** Fechar os furos que a auditoria crítica pós-implementação (2026-06-09, análise inline dos 18 gates + motor + CI + baselines) encontrou nas Fases 0–6 — antes de adicionar qualquer ferramenta nova na Fase 7.
|
||||
|
||||
**Architecture:** Zero ferramenta nova (tudo homegrown, padrão `check-*.mjs` + motor existente). Três frentes: (1) **bugs sistêmicos de runner** descobertos (testes órfãos, vitest fora do CI); (2) **endurecimento do padrão de catraca** (stale-allowlist enforcement + require-tighten, validados pela prática da Notion); (3) **expansão de escopo** dos gates existentes (diretórios/superfícies que ficaram de fora).
|
||||
|
||||
**Tech Stack:** Node ≥20 ESM, ESLint 9 flat, c8, jscpd@4 (a pinar), GitHub Actions, Node native test runner, vitest. Nada novo em `dependency-allowlist.json` exceto a promoção do jscpd a devDependency (Task 6A.12).
|
||||
|
||||
---
|
||||
|
||||
## Origem: o que a auditoria encontrou (resumo dos achados)
|
||||
|
||||
Método: releitura inline de todos os `scripts/check/*.{mjs,ts}` criados nas Fases 0–6, `scripts/quality/*`, baselines, `ci.yml`, `package.json`, hooks Husky e docs — sem subagentes — mais validação por pesquisa (sistema de ratcheting da Notion; práticas de suppression-hygiene de linters).
|
||||
|
||||
| # | Achado | Gravidade | Task |
|
||||
|---|--------|-----------|------|
|
||||
| A1 | **≈135 arquivos `*.test.ts` em subdiretórios de `tests/unit/` não são coletados por NENHUM runner** — `test:unit`, `test:coverage` e os shards do CI usam o glob não-recursivo `tests/unit/*.test.ts`; o vitest só inclui `autoCombo/**` (+ `.tsx`). Inclui `authz/routeGuard.test.ts` (Hard Rules #15/#17), 50 testes de `compression/`, 12 de `services/`, 10 de `gamification/`, 6 de `guardrails/`, 5 de `security/`. **Amostra rodada na auditoria: 2 asserts de `routeGuard.test.ts` FALHAM hoje** ("management policy allows /api/services/ (e /api/copilot/chat) from localhost with valid CLI token") — o arquivo apodreceu sem ninguém ver, provavelmente desde o redesign do peer-stamp (2026-05-31) | **P0 — falso verde sistêmico** | 6A.1 |
|
||||
| A2 | **Nenhum workflow roda `test:vitest`** (`grep -rln vitest .github/workflows/` = vazio). O CLAUDE.md afirma "Both test runners must pass… before merging", mas a suíte vitest (MCP server 43 tools, autoCombo, cache, componentes) está 100% fora da esteira | **P0** | 6A.2 |
|
||||
| B1 | **Nenhum gate falha quando uma entrada de allowlist deixa de ser necessária.** Os 18 gates congelam ~90 violações em `KNOWN_*`; quando alguém corrige a violação (ex.: criar `/api/gamification/level` da issue #3484, remover `krutrim` da #3483), a entrada vira um furo aberto — a regressão pode VOLTAR sem revisão. Só `check-error-helper` tem detecção parcial (WARN de arquivo inexistente, que ninguém lê). Prática validada: linters maduros "yell when an exclusion exists that doesn't break the rule" | **P0 — corrói a catraca com o tempo** | 6A.3 |
|
||||
| B2 | **Melhoria não-capturada vira folga permanente no motor**: sem `--update` manual, uma métrica que melhorou pode regredir de volta até o baseline antigo sem ninguém ver. A Notion auto-decrementa budgets no pre-commit; nosso motor não exige aperto | P1 | 6A.5 |
|
||||
| B3 | EPS único (0.01) para todas as métricas; o plano da Fase 4 pedia epsilon maior para `coverage.branches` (não-determinismo do v8) e não foi implementado | P1 | 6A.5 |
|
||||
| B4 | Métrica coletada sem entrada no baseline é ignorada em silêncio (coletor novo esquecido do baseline = falso conforto) | P2 | 6A.5 |
|
||||
| C1 | `check-fetch-targets` só varre `src/app/(dashboard)` — **20+ arquivos com `fetch("/api/…")` fora do escopo**: `src/shared/components/` (Sidebar, CommandPalette, modais…), `src/app/connect/`, `src/app/status/`, `src/lib/evals/` | P1 | 6A.7 |
|
||||
| C2 | `check-fetch-targets` ignora 100% dos template literals (`` fetch(`/api/x/${id}`) ``) — nem o prefixo estático é validado | P1 | 6A.7 |
|
||||
| C3 | `check-fetch-targets` não valida o método HTTP (fetch `POST` → rota só com `GET` = 405 em runtime, gate verde) | P2 | 6A.7 |
|
||||
| C4 | `check-deps` cobre só `package.json` raiz + `electron/` — **`@omniroute/opencode-plugin` (dep `zod` + 5 devDeps, pacote PUBLICADO no npm), `@omniroute/opencode-provider` e `open-sse/` ficam fora** | P1 | 6A.8 |
|
||||
| C5 | `check-public-creds` escaneia só 2 arquivos hardcoded — credencial literal em arquivo NOVO (executor, oauth provider) passa batida | P1 | 6A.8 |
|
||||
| C6 | `check-error-helper` cobre `open-sse/executors` + `handlers` — a Hard Rule #12 também fala de **MCP handlers** (`open-sse/mcp-server/`) e rotas HTTP (`src/app/api/`), fora do escopo | P1 | 6A.8 |
|
||||
| C7 | `check-file-size` e `check-complexity` varrem `src` + `open-sse` — `electron/` e `bin/` fora (god-file pode nascer lá) | P2 | 6A.11 |
|
||||
| C8 | `check-known-symbols` cobre executors/strategies/translators — **faltam as 3 superfícies de despacho por-string restantes: MCP tools (43), A2A skills (5, `A2A_SKILL_HANDLERS`), cloud agents (3, registry)** | P1 | 6A.9 |
|
||||
| C10 | `check-route-guard-membership` depende da lista manual `SPAWN_CAPABLE_ROUTE_ROOTS` (3 raízes) — rota nova que spawna processo FORA dessas raízes é invisível ao gate | P1 | 6A.8 |
|
||||
| C11 | `check-openapi-coverage` (THRESHOLD=36%) e `check-ui-keys-coverage` (65%) são pisos fixos manuais — não ratcheteiam; rotas/strings novas sem doc/i18n passam enquanto o % não cai do piso | P2 | 6A.11 |
|
||||
| C12 | `check-test-masking`: (a) `--diff-filter=M` não vê teste **DELETADO** (o masking mais brutal); (b) não vê `.skip`/`.todo`/`.only` adicionados (mantêm os asserts no texto, mas nunca rodam); (c) tautologia só cobre `assert.ok(true)` | P1 | 6A.10 |
|
||||
| D1 | **`ci.yml` roda gates apenas em `pull_request → main`** — todo o ciclo de PRs feature→`release/vX` passa SEM gate; as violações acumulam por semanas e estouram juntas no merge release→main (observado no gate da v3.8.18: 4 fixes de typecheck + ReDoS de última hora) | P1 — decisão do owner | 6A.6 |
|
||||
| D2 | `.husky/pre-push` está 100% comentado, mas o CLAUDE.md afirma "pre-push: npm run test:unit" (drift doc↔real) | P2 | 6A.12 |
|
||||
| D3 | **CLAUDE.md não menciona nenhum dos 18 gates** — um agente futuro não sabe que existem, qual a política de allowlist ("corrija, não congele"), nem como apertar baselines. Hard Rule #9 ainda diz "≥60%" (a catraca real é 80/80/82/73) | **P0 — anti-alucinação para os próprios agentes** | 6A.4 |
|
||||
| D4 | `check-duplication` roda `npx --yes jscpd@4` — pacote **não pinado por lockfile**, baixado do registry a cada run do CI (supply-chain + flakiness + latência); contraria o espírito do próprio `check-deps` | P2 | 6A.12 |
|
||||
| D5 | A skill `/quality-scan` roda ~20 comandos um a um — falta um runner agregador paralelo | P2 | 6A.12 |
|
||||
| D6 | Baselines de coverage com folga de ~2,5pt (80/80/82/73 vs real ~82,6/82,6/84,2/75,2) — a nota "_aperte após o 1º run verde_" existe no JSON mas não é tarefa de ninguém | P1 | 6A.5 |
|
||||
| E4 | Sem guarda contra artefato trackeado por engano — `node_modules` symlink já foi commitado 2× neste repo (`git add -A` em worktree) | P2 | 6A.12 |
|
||||
|
||||
**Decisões conscientes de NÃO fazer (avaliadas e descartadas, com motivo):**
|
||||
- **Gate de idempotência de migrations via regex** — SQLite não tem `ADD COLUMN IF NOT EXISTS`; idempotência vive em try/catch do runner; regex seria frágil (FP/FN). O `check-migration-numbering` + revisão humana bastam.
|
||||
- **Endurecer `check-docs-counts-sync` para fail** — contagens em prosa são heurísticas; soft-fail é o design correto. A cobertura de MCP tools entra pela 6A.9 (símbolos, não prosa).
|
||||
- **Sentido inverso do provider-consistency (providers.ts → REGISTRY)** — muitos providers canônicos legitimamente não têm entrada no REGISTRY (web/OAuth-only); a allowlist nasceria com dezenas de entradas e baixa razão sinal/ruído. Reavaliar quando o refactor #3501 tocar o split de providers.
|
||||
- **Complexidade por-função/por-arquivo (formato any-budget)** — upgrade real, mas o count global + `max-lines-per-function` já bloqueiam o grosso; o formato per-file entra junto com `sonarjs/cognitive-complexity` na Fase 7 Task 5 para não pagar duas migrações de baseline.
|
||||
|
||||
---
|
||||
|
||||
# Tasks
|
||||
|
||||
## P0 — bugs sistêmicos + documentação
|
||||
|
||||
### Task 6A.1 — `check-test-discovery` + religamento triado dos ~135 testes órfãos ⭐
|
||||
|
||||
**O achado nº1 da auditoria.** Testes que não rodam são o falso verde definitivo — todo o investimento anti test-masking da Fase 4 protege asserts de testes que **nem executam**.
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check/check-test-discovery.mjs` + `tests/unit/check-test-discovery.test.ts`
|
||||
- Modify: `package.json` (globs de `test:unit`, `test:coverage`), `.github/workflows/ci.yml` (globs dos shards 8×/node24/node26), `vitest.mcp.config.ts` ou `vitest.config.ts` (se algum subdir for re-homed para vitest)
|
||||
- Create: `test-discovery-baseline.json` (órfãos ainda-não-religados, catraca `down` até zerar)
|
||||
|
||||
**Approach (expandir em sub-plano na execução):**
|
||||
1. **Gate primeiro (TDD):** `check-test-discovery.mjs` enumera todo `**/*.{test,spec}.{ts,tsx}` do repo (fora de `node_modules`/`.next`) e verifica que cada arquivo é coletado por ≥1 runner: (a) globs do node test runner extraídos de `package.json`/`ci.yml`; (b) `include` dos dois `vitest.*config.ts`; (c) projetos Playwright. Órfão fora do baseline → exit 1. O baseline congela os órfãos atuais (catraca: não pode SUBIR; religamentos a fazem cair até `{}`).
|
||||
2. **Inventário verde/vermelho:** rodar cada subdir órfão isoladamente (`node --import tsx --test tests/unit/<dir>/*.test.ts`), registrar passa/falha. *Não* ligar tudo de uma vez — a amostra já provou que há vermelhos (`authz/routeGuard.test.ts`: 2 asserts).
|
||||
3. **Religar os verdes:** trocar o glob principal para recursivo — `"tests/unit/**/*.test.ts"` ENTRE ASPAS (expandido pelo test runner do Node, não pelo shell; **Step 0: validar o suporte a glob do runner na menor versão de Node suportada pelo repo** — fallback: listar os subdirs explicitamente) — em `test:unit`, `test:coverage` e nos 3 lugares do `ci.yml` (shards 8×, node24, node26). Remover religados do baseline.
|
||||
4. **Triar os vermelhos (Hard Rule #18 em cada um):** teste desatualizado → atualizar para o comportamento real (e provar que o comportamento real é o desejado); bug real revelado → fix TDD; teste de feature morta → deletar com justificativa no commit. Os 2 asserts do `routeGuard.test.ts` ("allows … with valid CLI token") são o primeiro caso: provável drift do peer-stamp de 2026-05-31 — MAS, por ser superfície de segurança (#15/#17), confirmar com cuidado que é o teste que está errado, não o guard.
|
||||
5. **Recalibrar cobertura:** religar ≈135 arquivos muda o denominador/numerador da cobertura — re-medir e apertar `quality-baseline.json` via `--update` no mesmo PR (resolve também o D6).
|
||||
|
||||
**Acceptance:** `check-test-discovery` no CI (job lint); zero órfãos fora do baseline; baseline decrescente documentado; suíte verde com os religados; cobertura recalibrada.
|
||||
|
||||
### Task 6A.2 — vitest no CI
|
||||
|
||||
**Files:** `.github/workflows/ci.yml` (job novo `test-vitest`, paralelo aos shards; NÃO tocar nos triggers — apenas adicionar job).
|
||||
|
||||
**Approach:** job com `npm ci` + `npm run test:vitest` (+ `test:vitest:ui` se o tempo couber; senão segundo step). Rodar localmente primeiro para garantir verde (a suíte passa hoje fora da esteira — confirmar). Se houver vermelho pré-existente, triagem antes do wire (mesmo protocolo da 6A.1 passo 4).
|
||||
|
||||
**Acceptance:** PR→main roda as DUAS suítes; o claim do CLAUDE.md ("both must be green") vira verdade mecânica.
|
||||
|
||||
### Task 6A.3 — Stale-allowlist enforcement em todos os gates (suppression hygiene)
|
||||
|
||||
**O endurecimento sistêmico nº1.** Padrão validado (ESLint `--report-unused-disable-directives`; Notion): exclusão que não exclui nada vivo é dívida fantasma e furo de regressão.
|
||||
|
||||
**Files:** todos os gates com allowlist + seus testes:
|
||||
`check-fetch-targets` (KNOWN_MISSING, 7) · `check-provider-consistency` (KNOWN_REGISTRY_ONLY, 1) · `check-openapi-routes` (KNOWN_STALE_SPEC, 1) · `check-public-creds` (KNOWN_LITERAL_CREDS, 5) · `check-db-rules` (KNOWN_UNEXPORTED 25 + KNOWN_RAW_SQL 15) · `check-docs-symbols` (KNOWN_STALE_DOC_REFS, 30) · `check-migration-numbering` (KNOWN_GAPS/DUPLICATES) · `check-error-helper` (KNOWN_MISSING_ERROR_HELPER, 7 — promover o WARN existente a FAIL e cobrir também "arquivo existe mas não viola mais") · `check-deps` (dependency-allowlist: entrada sem dep correspondente em manifest algum = stale) · `check-file-size` (entrada `frozen` cujo arquivo foi deletado/renomeado) · `check-route-guard-membership` (KNOWN_UNCLASSIFIED — vazio hoje; implementar o check para quando deixar de ser).
|
||||
|
||||
**Approach (mecânica única, TDD por gate):** após a detecção normal, re-avaliar cada entrada da allowlist: *"se esta entrada não existisse, o gate flagaria algo?"* — para allowlists de path/valor isso é `violationsDetectadas.has(entry)`; para arquivos, existência + violação presente. Entrada que não suprime nada → **exit 1** com mensagem `entrada obsoleta — a violação foi corrigida; REMOVA a entrada para travar a correção`. Extrair helper comum `reportStaleEntries(allowlist, liveViolations, gateName)` em `scripts/check/lib/allowlist.mjs` para não duplicar 11×.
|
||||
|
||||
**Acceptance:** corrigir qualquer violação congelada (ex.: as issues #3483–#3501) passa a EXIGIR a remoção da entrada no mesmo PR; teste sintético prova fail-on-stale em cada gate.
|
||||
|
||||
### Task 6A.4 — Documentar os gates no CLAUDE.md (+ corrigir drifts de doc)
|
||||
|
||||
**Files:** `CLAUDE.md`, `AGENTS.md` (se houver seção espelho), `docs/architecture/` (página `QUALITY_GATES.md` referenciada pela tabela de docs).
|
||||
|
||||
**Approach:** (1) seção nova "Quality Gates & Ratchets" no CLAUDE.md: tabela dos gates (nome → o que trava → allowlist/baseline), a política **"corrija a causa; allowlist só com justificativa + issue"**, como apertar (`npm run quality:ratchet -- --update`, `check:<gate> -- --update`), e o que fazer quando um gate falha num PR. (2) Corrigir: Hard Rule #9 (60% → "catraca de cobertura: nunca abaixo do baseline congelado em `quality-baseline.json`; piso absoluto 60"), claim do pre-push (refletir o real pós-6A.12), claim "both runners" (verdade pós-6A.2). (3) Página `docs/architecture/QUALITY_GATES.md` com o detalhe operacional (o CLAUDE.md fica curto, linka). Rodar `check:docs-all` após editar (o próprio docs-sync valida).
|
||||
|
||||
**Acceptance:** agente novo lendo o CLAUDE.md descobre os gates e a política sem ler scripts; `check:docs-all` verde.
|
||||
|
||||
## P1 — endurecimento do motor + escopos
|
||||
|
||||
### Task 6A.5 — Motor v2: `--require-tighten`, eps por métrica, métricas órfãs
|
||||
|
||||
**Files:** `scripts/quality/check-quality-ratchet.mjs`, `quality-baseline.json` (schema), `tests/unit/quality-ratchet.test.ts`, `.github/workflows/ci.yml` (flag no job quality-gate).
|
||||
|
||||
**Approach (TDD):**
|
||||
1. Schema por métrica ganha campos opcionais: `eps` (default 0.01) e `tightenSlack` (default: igual a `eps`).
|
||||
2. Novo modo `--require-tighten` (ligado no CI): se `atual` melhor que `baseline` além de `tightenSlack`, **exit 1** com `melhorou de X para Y — rode 'npm run quality:ratchet -- --update' e commite o baseline apertado neste PR`. Métricas determinísticas (`eslintWarnings`) usam slack 0; cobertura usa slack 1.5 (flutuação v8). É o "auto-decrement" da Notion adaptado a CI sem bot de commit.
|
||||
3. Warning para métricas presentes em `quality-metrics.json` sem entrada no baseline (coletor órfão).
|
||||
4. Calibrar os 4 `coverage.*` para o real medido (fecha D6 — coordenar com a 6A.1 passo 5, que muda a base).
|
||||
|
||||
**Acceptance:** melhoria sem aperto de baseline falha no CI; flutuação de coverage dentro do slack não falha; testes cobrem os 3 comportamentos novos.
|
||||
|
||||
### Task 6A.6 — `quality.yml`: gates rápidos em PRs → `release/**` ⚠️ DECISÃO DO OWNER
|
||||
|
||||
**Contexto sensível:** o owner já reverteu mudança de trigger no `ci.yml` ("não mexe na CI"). Esta task **não toca o `ci.yml`** — cria um workflow NOVO e enxuto. Ainda assim, **passo 0 = confirmação explícita do owner**.
|
||||
|
||||
**Files:** Create: `.github/workflows/quality.yml`.
|
||||
|
||||
**Approach:** `on: pull_request: branches: ["release/**"]`; um job único (~1–2 min) só com os gates determinísticos filesystem-only: provider-consistency, fetch-targets, openapi-routes, docs-symbols, deps, file-size, error-helper, migration-numbering, public-creds, db-rules, known-symbols, route-guard-membership, test-discovery (pós-6A.1) + `check:any-budget:t11`. SEM lint/test/build (continuam só no PR→main). Ganho: a violação aparece no PR que a introduz, não semanas depois no gate do release (padrão observado: 4 fixes de última hora no release da v3.8.18).
|
||||
|
||||
**Acceptance:** PR de teste contra a release branch com uma rota inventada falha em <2 min; PRs limpos não ganham mais que ~2 min de CI.
|
||||
|
||||
### Task 6A.7 — `check-fetch-targets` v2: escopo completo + prefixo de template + método HTTP
|
||||
|
||||
**Files:** `scripts/check/check-fetch-targets.mjs`, `tests/unit/check-fetch-targets.test.ts`.
|
||||
|
||||
**Approach (TDD):**
|
||||
1. **Escopo:** varrer todo `src/**/*.{ts,tsx}` client-side (excluindo `src/app/api/**`, testes, `src/lib/db`), não só `(dashboard)` — congela os misses pré-existentes que aparecerem em `KNOWN_MISSING` (com triagem/issue por cluster, igual Fase 2).
|
||||
2. **Template literals:** extrair o prefixo estático de `` fetch(`/api/x/y/${id}…`) `` e validar por **prefix-match** contra as rotas reais (existe alguma rota cujo path começa com `/api/x/y/`?). Pega diretório inteiro alucinado; não tenta resolver o sufixo dinâmico.
|
||||
3. **Método HTTP (heurístico, mesma chamada):** quando o 2º argumento literal contém `method: "POST"` (etc.), verificar que o `route.ts` resolvido exporta a função correspondente (`grep` por `export (async )?function POST` / `export const POST`). Sem method literal → assume GET-ok (rota existe basta). Casos dinâmicos → skip silencioso.
|
||||
|
||||
**Acceptance:** fixture com fetch em `src/shared/components` + template com prefixo falso + `method: "DELETE"` para rota só-GET — 3 detecções; repo real verde com os novos congelados documentados.
|
||||
|
||||
### Task 6A.8 — Escopo dos gates de segurança: error-helper, public-creds, route-guard, deps
|
||||
|
||||
**Files:** `check-error-helper.mjs`, `check-public-creds.mjs`, `check-route-guard-membership.ts`, `check-deps.mjs` + testes.
|
||||
|
||||
**Approach (TDD, um sub-commit por gate):**
|
||||
1. **error-helper** (+Rule #12 completa): incluir `open-sse/mcp-server/**` e `src/app/api/**/route.ts` no SCAN_DIRS; rodar; congelar os achados novos em KNOWN com comentário-justificativa cada (e issue por cluster).
|
||||
2. **public-creds**: além dos 2 arquivos âncora, varrer `open-sse/**` e `src/lib/oauth/**` com a mesma `CRED_KEY_RE` (linha a linha, barato); congelar achados. Limitação documentada: `const CLIENT_ID = "…"` (variável solta) continua fora — o gitleaks da Fase 7 cobre essa classe.
|
||||
3. **route-guard**: novo sub-check — todo `route.ts` (qualquer raiz) cujo fonte OU imports de 1º nível relativos contenham `child_process`/`spawn(`/`execFile(`/`worker_threads` deve ser classificado local-only por `isLocalOnlyPath()`. Mata a dependência da lista manual de 3 raízes.
|
||||
4. **deps**: `MANIFESTS` → descoberta automática de todo `package.json` do repo (fora `node_modules`/`.next`): hoje raiz, `electron/`, `open-sse/`, `@omniroute/opencode-plugin/` (dep `zod` entra na allowlist), `@omniroute/opencode-provider/`. Workspace novo amanhã entra sozinho.
|
||||
|
||||
**Acceptance:** fixtures sintéticas por gate; repo real verde com achados congelados + issues; dep nova em QUALQUER manifest do repo dispara o gate.
|
||||
|
||||
### Task 6A.9 — `check-known-symbols` v2: MCP tools, A2A skills, cloud agents
|
||||
|
||||
**Files:** `scripts/check/check-known-symbols.ts`, `tests/unit/check-known-symbols.test.ts`.
|
||||
|
||||
**Approach (TDD, mesmo padrão das 3 superfícies existentes — Step 0 de verificação dos exports reais antes de codar):**
|
||||
1. **MCP tools:** enumerar os tools registrados (via `createMcpServer()` ou parse determinístico do tool-set em `open-sse/mcp-server/tools/`) e congelar o snapshot de nomes (catraca: tool sumir = fail; tool novo = report). Cruzar com os scopes (~13) — tool sem scope atribuído = fail.
|
||||
2. **A2A skills:** chaves de `A2A_SKILL_HANDLERS` (`src/lib/a2a/taskExecution.ts`) ↔ skills expostas no Agent Card (`src/app/.well-known/agent.json/route.ts`) — divergência = fail.
|
||||
3. **Cloud agents:** entradas do `src/lib/cloudAgent/registry.ts` ↔ classes em `agents/` — incompleto/órfão = fail.
|
||||
|
||||
**Acceptance:** remover um tool/skill/agent do registro quebra o gate; adicionar reporta (e `check-docs-counts-sync` continua cuidando da prosa).
|
||||
|
||||
## P2 — refinamentos
|
||||
|
||||
### Task 6A.10 — `check-test-masking` v2: deleções, skips, tautologias
|
||||
|
||||
**Files:** `scripts/check/check-test-masking.mjs`, `tests/unit/check-test-masking.test.ts`.
|
||||
|
||||
**Approach (TDD):** (a) `--diff-filter=M` → `MDR` (com `-M` para rename-detection): arquivo de teste **deletado** = flag automático ("N asserts removidos — arquivo deletado"); renamed = comparar contra o path antigo. (b) Contar `\.(skip|todo|only)\s*\(` + `\{\s*skip:\s*true` base vs HEAD — **aumento líquido de skips = flag** (skip novo esconde asserts sem removê-los); `.only` novo = flag sempre (filtra o resto da suíte). (c) Tautologias extras: `expect(true).toBe(true)`, `assert.equal(1, 1)`, `expect(x).toBeDefined()` como ÚNICO assert do teste.
|
||||
|
||||
**Acceptance:** fixtures para os 3 bypasses (delete, skip, only) — todos flagados; suíte real verde.
|
||||
|
||||
### Task 6A.11 — Pisos manuais → catraca do motor + escopo electron/bin
|
||||
|
||||
**Files:** `scripts/quality/collect-metrics.mjs`, `quality-baseline.json`, `check-openapi-coverage.mjs`, `scripts/i18n/check-ui-keys-coverage.mjs` (só leitura do valor), `check-file-size.mjs`, `eslint.complexity.config.mjs` + baselines.
|
||||
|
||||
**Approach:** (1) coletor emite `openapiCoverage.pct` e `i18nUiCoverage.pct` → baseline `{direction: up}` com o valor real atual (36→real, 65→real); os THRESHOLDs fixos viram redundância de segurança (mantidos). Rotas/strings novas sem doc/i18n agora REGRIDEM o % e falham. (2) `check-file-size` e `check-complexity`: adicionar `electron/` e `bin/` ao scan (congelar os >cap que existirem).
|
||||
|
||||
**Acceptance:** rota nova não-documentada derruba `openapiCoverage.pct` → gate falha; god-file novo em `electron/` falha.
|
||||
|
||||
### Task 6A.12 — Higiene operacional (4 itens pequenos)
|
||||
|
||||
**Files:** `package.json`, `dependency-allowlist.json`, `.husky/pre-push`, Create: `scripts/check/check-tracked-artifacts.mjs`, `scripts/quality/run-all-gates.mjs`; Modify: `.agents/skills/quality-scan/SKILL.md`.
|
||||
|
||||
**Approach:**
|
||||
1. **jscpd pinado:** `jscpd@^4` como devDependency (entra no lockfile + allowlist); `check-duplication` chama o binário local em vez de `npx --yes jscpd@4` (remove download de registry no CI + supply-chain risk + flakiness).
|
||||
2. **pre-push barato:** reativar com APENAS os gates determinísticos rápidos (<10s: fetch-targets, openapi-routes, db-rules, public-creds, migration-numbering, file-size, deps, error-helper) — NÃO `test:unit` (lento; CI cobre). Atualizar o claim do CLAUDE.md (coordenar com 6A.4).
|
||||
3. **check-tracked-artifacts:** falhar se `git ls-files` contém `node_modules/`, `.next/`, `coverage/`, `quality-metrics.json` ou symlink para fora do repo (o incidente do symlink trackeado já aconteceu 2×). Wire no lint job + pre-commit (é instantâneo).
|
||||
4. **Runner agregador:** `scripts/quality/run-all-gates.mjs` roda os gates em paralelo (pool ~4), agrega `{gate, exitCode, lastLine, durationMs}` e imprime a tabela consolidada; `npm run quality:scan`. A skill `/quality-scan` passa a chamá-lo (atualizar SKILL.md).
|
||||
|
||||
**Acceptance:** `npm run quality:scan` < 3 min com tabela única; pre-push roda <10s; `git add node_modules && commit` falha no pre-commit.
|
||||
|
||||
---
|
||||
|
||||
## Ordem de execução recomendada (na ativação, 2026-06-16+)
|
||||
|
||||
1. **6A.1 + 6A.2** (bugs de runner — destravam números reais de cobertura para o resto)
|
||||
2. **6A.3 + 6A.4** (stale-enforcement + docs — endurecem o que já roda)
|
||||
3. **6A.5** (motor v2) → **6A.6** (quality.yml, após OK do owner)
|
||||
4. **6A.7 → 6A.9** (escopos)
|
||||
5. **6A.10 → 6A.12** (refinamentos)
|
||||
6. Só então **Fase 7** (ferramentas novas sobre uma fundação consertada)
|
||||
|
||||
## Self-Review
|
||||
- **Cobertura dos achados:** todos os achados A*/B*/C*/D*/E* da tabela têm task (coluna Task); os descartados estão em "Decisões conscientes de NÃO fazer" com motivo. ✓
|
||||
- **Zero dependência nova** exceto a promoção do jscpd (que já roda hoje via npx, não-pinado — a task REDUZ risco). ✓
|
||||
- **Sem flag-day:** toda expansão de escopo congela os achados pré-existentes (allowlist + issue), igual Fases 0–6; o stale-enforcement só exige remoção quando a correção JÁ aconteceu. ✓
|
||||
- **Consistência com o motor:** novas métricas (`openapiCoverage.pct`, `i18nUiCoverage.pct`) usam o formato `{value, direction}`; `eps`/`tightenSlack` são opcionais e retrocompatíveis. ✓
|
||||
- **Não-duplicação com a Fase 7:** cognitive-complexity per-file, gitleaks (creds não-públicas), knip, osv — tudo continua na Fase 7; a 6A só conserta/endurece o existente. ✓
|
||||
@@ -1,182 +0,0 @@
|
||||
# Fase 7 — Quality Gates: Segurança, Dead-Code, Mutação & Ferramental Community
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans`, tarefa-a-tarefa. Cada tarefa aqui é um subsistema independente → **expandir em sub-plano bite-sized próprio** no momento da execução. Hard Rule #18 (TDD/VPS) em tudo.
|
||||
|
||||
> # ⏳ PORTÃO DE ATIVAÇÃO — NÃO INICIAR ANTES DE **2026-06-16**
|
||||
> **Este plano está GUARDADO, não ativo.** Decisão do owner (2026-06-09): finalizar 100% as Fases 0–6 (PR #3471), **usar em produção por 1 semana** para validar na prática, e só então evoluir. **Data cravada de início da Fase 7: 2026-06-16.** Não ativar antes — o objetivo da semana é coletar sinal real (falsos-positivos dos gates, custo de CI, atrito) antes de adicionar mais.
|
||||
> **Pré-condições para ativar:** (1) PR #3471 (Fases 0–6) mergeada e rodada ≥1 semana; (2) re-home do PR para `release/v3.8.18` resolvido; (3) as issues #3483–#3501 com decisões aplicadas ou conscientemente adiadas; (4) **Fase 6A executada (ou conscientemente re-priorizada)** — a auditoria crítica de 2026-06-09 ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) encontrou bugs sistêmicos de runner (≈135 testes órfãos, vitest fora do CI) e furos de escopo nos gates existentes que devem ser consertados ANTES de adicionar ferramentas novas por cima.
|
||||
|
||||
**Goal:** Maximizar a cobertura de quality gates do OmniRoute adicionando catracas de **segurança** (Sonar/osv/CodeQL → zero na timeline), **dead-code**, **complexidade cognitiva**, **type-coverage**, **mutação**, **bundle-size**, **a11y** e completando o anti-slopsquatting — usando **somente ferramentas Community/OSS** (projeto é open-source, zero SaaS pago, dados na box).
|
||||
|
||||
**Architecture:** Reusa o motor existente — toda métrica numérica entra como `{value, direction}` em `quality-baseline.json` (catraca só-regressão) ou vira um `scripts/check/check-*.mjs` dedicado (padrão `check-t11-any-budget.mjs`). Gates pesados vão no job paralelo `quality-gate`; gates rápidos no `lint`; mutação/visual em job nightly separado. Tudo só-regressão (sem flag-day).
|
||||
|
||||
**Tech Stack (tudo OSS/Community):** SonarQube **Community Build** (self-hosted) · osv-scanner (Google) · CodeQL (GitHub, grátis p/ público) · knip · eslint-plugin-sonarjs · type-coverage · lockfile-lint · dpdm · Stryker (`@stryker-mutator/*`) · size-limit · `@axe-core/playwright` · semcheck · agent-lsp (MCP) · Qlty CLI (OSS, opcional) · **gitleaks** (secret scanning, MIT; avaliar o sucessor drop-in Betterleaks, 2026-03) · **actionlint + zizmor** (lint + auditoria de segurança dos workflows) · **license-compliance** (allowlist SPDX de licenças). ESLint 9 flat · c8 · Node native test runner · GitHub Actions.
|
||||
|
||||
---
|
||||
|
||||
## Princípio (igual às Fases 0–6)
|
||||
|
||||
Toda catraca é **só-regressão**: congela o baseline atual, bloqueia QUALQUER piora, decai a zero/melhor com o tempo via `--update`. Nenhum gate exige limpeza imediata (flag-day). Cada ferramenta nova que vira dependência **deve ser adicionada a `dependency-allowlist.json`** (o gate `check-deps` da Fase 2 vai exigir — é o ponto de revisão humana).
|
||||
|
||||
## Mapa de arquivos (criar/modificar)
|
||||
|
||||
| Arquivo | Responsabilidade |
|
||||
|---|---|
|
||||
| `quality-baseline.json` (modificar) | + `vulnCount`, `codeqlAlerts`, `sonarIssues`, `cognitiveComplexity`, `typeCoveragePct`, `deadExports` |
|
||||
| `scripts/quality/collect-metrics.mjs` (modificar) | + coletores: osv-scanner, CodeQL count, Sonar API, knip, type-coverage, sonarjs |
|
||||
| `scripts/check/check-vuln-ratchet.mjs` (criar) | osv-scanner → vulnCount (catraca) |
|
||||
| `scripts/check/check-dead-code.mjs` (criar) | knip → exports/files/deps mortos (catraca) |
|
||||
| `scripts/check/check-cognitive-complexity.mjs` + `eslint.sonarjs.config.mjs` (criar) | sonarjs/cognitive-complexity em config isolado (não polui o count principal) |
|
||||
| `scripts/check/check-type-coverage.mjs` (criar) | type-coverage % (catraca up) |
|
||||
| `scripts/check/check-lockfile.mjs` (criar) | lockfile-lint (host/https/integrity) |
|
||||
| `scripts/check/check-pr-evidence.mjs` (criar) | exige output de comando no corpo do PR (Rule #18 mecânica) |
|
||||
| `scripts/check/check-bundle-size.mjs` + `.size-limit.json` (criar) | size-limit → orçamento de bundle |
|
||||
| `tests/e2e/a11y.spec.ts` (criar) | `@axe-core/playwright` nas páginas-chave |
|
||||
| `stryker.conf.json` (criar) | mutação nos ~8 módulos críticos (nightly) |
|
||||
| `sonar-project.properties` (modificar) | remover `coverage`/`cpd` exclusions; ativar new-code gate |
|
||||
| `.github/workflows/ci.yml` (modificar) | wirar novos gates (lint / quality-gate / nightly) + `qualitygate.wait` no Sonar |
|
||||
| `semcheck.yaml` (criar) | semcheck: docs↔código (camada fuzzy LLM, opcional) |
|
||||
| `.mcp.json` / config de agentes (modificar) | registrar agent-lsp (LSP-in-the-loop) |
|
||||
| `.gitleaks.toml` + `scripts/check/check-secrets.mjs` (criar) | gitleaks → catraca de findings de secret (Task 18) |
|
||||
| `.github/workflows/quality.yml` ou job lint (modificar) | actionlint + zizmor sobre `.github/workflows/**` (Task 19) |
|
||||
| `scripts/check/check-licenses.mjs` + `.license-allowlist.json` (criar) | allowlist SPDX das licenças das deps (Task 20) |
|
||||
| `dependency-allowlist.json` (modificar) | + osv-scanner, knip, sonarjs, type-coverage, lockfile-lint, stryker, size-limit, axe-core, dpdm, license-compliance |
|
||||
|
||||
---
|
||||
|
||||
## Tarefas (cada uma = 1 sub-plano bite-sized na execução)
|
||||
|
||||
### Task 1 — Ativar SonarQube Community + "Clean as You Code" (gate de segurança nativo)
|
||||
- **Tool:** SonarQube Community Build (self-hosted, grátis).
|
||||
- **Files:** `sonar-project.properties`, `.github/workflows/ci.yml` (job `sonarqube`).
|
||||
- **Approach:** setar secrets `SONAR_TOKEN`/`SONAR_HOST_URL`; **remover** `sonar.coverage.exclusions=**/*` e `sonar.cpd.exclusions=**/*` (hoje neutralizam o Sonar); ativar o quality gate **new-code / "Clean as You Code"** (código novo não pode adicionar issue/bug/vuln/hotspot; legado grandfathered) + adicionar `-Dsonar.qualitygate.wait=true` para **bloquear** o PR (hoje o job é inerte: secrets-gated, sem wait).
|
||||
- **Acceptance:** PR que introduz um code-smell/bug/vuln em código novo falha o gate; legado não bloqueia. Documentar suppressions legítimas (já há h1–h6 no properties).
|
||||
|
||||
### Task 2 — Catraca de vulnerabilidades (osv-scanner)
|
||||
- **Tool:** osv-scanner (Google/OSV, OSS) — `--format json`, on-box.
|
||||
- **Files:** `scripts/check/check-vuln-ratchet.mjs`, `quality-baseline.json` (+`vulnCount`), `collect-metrics.mjs`, `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar `osv-scanner --format json` sobre os lockfiles → contar vulns → métrica `vulnCount {direction: down}`. Catraca: não pode subir, decai a zero. Mantém o `npm audit` escalonado da Fase 0 como bloqueio de crítico imediato; osv é o ratchet de timeline.
|
||||
- **Acceptance:** nova dep com vuln conhecida sobe o count → falha; remediar/remover baixa → `--update`.
|
||||
|
||||
### Task 3 — Catraca de alertas CodeQL
|
||||
- **Tool:** GitHub CodeQL (já roda; grátis p/ repo público).
|
||||
- **Files:** `scripts/check/check-codeql-ratchet.mjs`, `quality-baseline.json` (+`codeqlAlerts`), `ci.yml`.
|
||||
- **Approach:** puxar a contagem de alertas abertos via `gh api /repos/{owner}/{repo}/code-scanning/alerts?state=open` → métrica `codeqlAlerts {down}`. (Respeitar Hard Rule #14 — dismiss só com justificativa; alertas dismissed não contam.)
|
||||
- **Acceptance:** novo alerta CodeQL sobe o count → sinaliza; resolver baixa.
|
||||
|
||||
### Task 4 — Dead-code / unused-exports / unused-deps (knip)
|
||||
- **Tool:** knip (OSS, v6+) — `--reporter json`.
|
||||
- **Files:** `scripts/check/check-dead-code.mjs`, `quality-baseline.json` (+`deadExports`/`unusedDeps`), `knip.json` (config), `collect-metrics.mjs`, `ci.yml`, `dependency-allowlist.json`.
|
||||
- **Approach:** `knip --reporter json` sobre os workspaces `src/`+`open-sse/` → contar unused files/exports/deps → catraca `down`. Config knip ciente do monorepo + Next 16.
|
||||
- **Acceptance:** novo export/dep morto sobe → falha; remoção baixa.
|
||||
|
||||
### Task 5 — Complexidade cognitiva (eslint-plugin-sonarjs, config isolado)
|
||||
- **Tool:** eslint-plugin-sonarjs (OSS) — `sonarjs/cognitive-complexity`.
|
||||
- **Files:** `eslint.sonarjs.config.mjs` (config standalone, NÃO o principal — não polui o `eslintWarnings=3482`), `scripts/check/check-cognitive-complexity.mjs`, `quality-baseline.json` (+`cognitiveComplexity`), `ci.yml` (job `quality-gate`), `dependency-allowlist.json`.
|
||||
- **Approach:** mesmo molde do `check-complexity` (Fase 6) mas com `sonarjs/cognitive-complexity` num config isolado; contar violações → catraca `down`. (Complementa a complexidade ciclomática core já existente.)
|
||||
- **Acceptance:** função acima do limite cognitivo sobe o count → falha.
|
||||
|
||||
### Task 6 — Type-coverage ratchet
|
||||
- **Tool:** type-coverage (OSS).
|
||||
- **Files:** `scripts/check/check-type-coverage.mjs`, `quality-baseline.json` (+`typeCoveragePct {up}`), `dependency-allowlist.json`.
|
||||
- **Approach:** `type-coverage --detail --json` → % de símbolos tipados → catraca `up`. Complementa o `check:any-budget` (count de `any` por arquivo) com a visão %-global.
|
||||
- **Acceptance:** queda do % tipado → falha.
|
||||
|
||||
### Task 7 — Lockfile policy (lockfile-lint)
|
||||
- **Tool:** lockfile-lint (OSS, v5).
|
||||
- **Files:** `scripts/check/check-lockfile.mjs`, `ci.yml` (lint), `dependency-allowlist.json`.
|
||||
- **Approach:** `lockfile-lint --path package-lock.json --type npm --validate-https --validate-integrity --allowed-hosts npm` → gate pass/fail (não é ratchet; é política anti-poisoning). Complementa o `check-deps` (Fase 2).
|
||||
- **Acceptance:** lockfile com host não-https/sem integrity → falha.
|
||||
|
||||
### Task 8 — Completar anti-slopsquatting (registry-existence + age-cooldown)
|
||||
- **Tool:** npm registry API (`npm view <pkg> time.created`).
|
||||
- **Files:** `scripts/check/check-deps.mjs` (estender), test.
|
||||
- **Approach:** além do allowlist-diff atual, para uma dep NOVA: verificar que existe no registry E que foi publicada há ≥72h (age-cooldown contra "registra o nome alucinado em horas"). Base: CSA 2026 (19,7% de nomes alucinados; 43% reaparecem).
|
||||
- **Acceptance:** dep nova inexistente no registry ou publicada há <72h → falha (a menos que allowlistada com justificativa).
|
||||
|
||||
### Task 9 — Pisos de cobertura por módulo crítico (peça adiada da Fase 4)
|
||||
- **Files:** `scripts/quality/collect-metrics.mjs` (estender), `quality-baseline.json`.
|
||||
- **Approach:** emitir `coverage.<modulo>.lines` (lido do `coverage-summary.json` por-arquivo) para ~8 módulos de alto risco: `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up`. **Calibrar a partir do coverage mergeado real do 1º run verde na main.**
|
||||
- **Acceptance:** queda de cobertura num módulo crítico → falha, mesmo que o global não caia.
|
||||
|
||||
### Task 10 — Evidence-in-PR-body (peça adiada da Fase 5)
|
||||
- **Files:** `scripts/check/check-pr-evidence.mjs`, `ci.yml` (job `pr-test-policy`).
|
||||
- **Approach:** se o corpo do PR afirma "tests pass"/"added endpoint X"/"fixed Y" sem um bloco de **output de comando** anexado (typecheck/test/grep), falha (torna a Rule #18 mecânica — "evidence before assertions"). Heurístico, no contexto de PR.
|
||||
- **Acceptance:** PR alegando sucesso sem output anexado → falha.
|
||||
|
||||
### Task 11 — Mutation testing nos módulos críticos (Stryker, nightly)
|
||||
- **Tool:** Stryker (`@stryker-mutator/core` + runner; OSS).
|
||||
- **Files:** `stryker.conf.json`, `ci.yml` (job NIGHTLY separado — não no PR), `dependency-allowlist.json`.
|
||||
- **Approach:** escopar Stryker aos ~8 módulos críticos da Task 9 (não repo-wide — é caro + c8 já OOM-prone). Mutantes sobreviventes = **testes tautológicos** (passam sem provar nada) → complementa o `check-test-masking` da Fase 4. Rodar nightly/weekly, não por-PR.
|
||||
- **Acceptance:** mutation score por módulo crítico vira métrica (catraca `up`, nightly).
|
||||
|
||||
### Task 12 — Bundle-size / perf budget (size-limit)
|
||||
- **Tool:** size-limit (OSS).
|
||||
- **Files:** `.size-limit.json`, `scripts/check/check-bundle-size.mjs`, `ci.yml`, `dependency-allowlist.json`.
|
||||
- **Approach:** definir orçamento por bundle Next 16; size-limit emite tamanhos → catraca `down` (bundle não pode inchar).
|
||||
- **Acceptance:** PR que estoura o orçamento de bundle → falha.
|
||||
|
||||
### Task 13 — a11y gate (axe-core + Playwright)
|
||||
- **Tool:** `@axe-core/playwright` (OSS; Playwright já existe).
|
||||
- **Files:** `tests/e2e/a11y.spec.ts`, `ci.yml` (job `test-e2e`), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar axe nas páginas-chave do dashboard; congelar violações atuais (catraca `down`). Atende o item a11y/visual do plano t15.
|
||||
- **Acceptance:** nova violação a11y → falha; correção baixa.
|
||||
|
||||
### Task 14 — semcheck (camada fuzzy docs↔código, opcional/LLM)
|
||||
- **Tool:** semcheck (OSS, MIT) — `fail-on-issues`.
|
||||
- **Files:** `semcheck.yaml`, `ci.yml` (advisory).
|
||||
- **Approach:** regras ligando `docs/**` ao módulo de código que documentam; pega docs que descrevem o que o código NÃO faz (camada fuzzy sobre o determinístico `check-docs-symbols` da Fase 6). É LLM → rodar advisory/non-blocking ou em label, por custo.
|
||||
- **Acceptance:** doc que descreve comportamento inexistente → flag (advisory).
|
||||
|
||||
### Task 15 — agent-lsp (LSP-in-the-loop para os agentes)
|
||||
- **Tool:** agent-lsp (MCP server, OSS).
|
||||
- **Files:** config MCP dos agentes (`.mcp.json`/equivalente).
|
||||
- **Approach:** expor `tsserver`/agent-lsp aos agentes para `blast_radius`/diagnostics/`preview_edit` ANTES de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
|
||||
- **Acceptance:** agentes resolvem símbolo/import via LSP; menos alucinação de símbolo na origem.
|
||||
|
||||
### Task 16 — dpdm circular-deps JSON cross-check (opcional)
|
||||
- **Tool:** dpdm (OSS, v4) — `--circular --output`.
|
||||
- **Approach:** cross-check JSON de ciclos complementando o `check-cycles.mjs` existente (AST-TS mais preciso). Catraca de contagem de ciclos. Baixa prioridade (já temos check-cycles).
|
||||
|
||||
### Task 17 — Avaliar Qlty CLI como consolidador (opcional, spike)
|
||||
- **Tool:** Qlty CLI (OSS, grátis).
|
||||
- **Approach:** spike: avaliar se Qlty (Baseline analysis + 70 analyzers) substitui N scripts caseiros sem perder o controle/determinismo. Decisão build-vs-buy. Não obrigatório.
|
||||
|
||||
### Task 18 — Secret scanning local (gitleaks) ➕ *adicionada pela auditoria 6A (2026-06-09)*
|
||||
- **Tool:** gitleaks (OSS, MIT — binário Go, on-box). Nota 2026: o criador original (Zach Rice) lançou o **Betterleaks** (2026-03) como drop-in replacement (flags/config compatíveis) — avaliar os dois no Step 0 e escolher 1.
|
||||
- **Files:** `.gitleaks.toml`, `scripts/check/check-secrets.mjs`, `quality-baseline.json` (+`secretFindings {down}`), `ci.yml` (job quality-gate), pre-commit (modo `--staged`, é rápido).
|
||||
- **Approach:** complementa o `check-public-creds` da Fase 6 (que cobre apenas credenciais PÚBLICAS por chave de objeto em 2 arquivos): gitleaks pega a classe geral — `const API_KEY = "sk-…"`, tokens em config/teste/docs, secrets em histórico. Rodar `gitleaks dir --report-format json` → contar findings → catraca `down`. Findings legítimos (creds públicas já congeladas no check-public-creds, fixtures de teste) vão para `.gitleaks.toml` `[allowlist]` com comentário — sujeitos ao stale-enforcement da 6A.3 (conceitual: revisar allowlist a cada release).
|
||||
- **Acceptance:** secret real plantado em fixture é detectado; baseline congela os findings atuais; novo finding falha o gate.
|
||||
|
||||
### Task 19 — Lint + auditoria de segurança dos workflows (actionlint + zizmor) ➕ *adicionada pela auditoria 6A*
|
||||
- **Tools:** actionlint (OSS — correção/sintaxe/shellcheck dos YAML) + zizmor (OSS, zizmorcore — 24+ audits de segurança: unpinned actions, script injection, `pull_request_target` perigoso, cache poisoning). Complementares por design; o repo tem 10 workflows sem NENHUMA validação hoje.
|
||||
- **Motivação 2026:** o incidente trivy-action/LiteLLM (2026-03) explorou exatamente uma misconfiguração de `pull_request_target` que o zizmor detecta estaticamente. Os release-workflows do OmniRoute (npm/Docker/Electron publish) são alvo de alto valor.
|
||||
- **Files:** `ci.yml` ou `quality.yml` (steps actionlint + zizmor), `zizmor.yml` (config/ignores justificados), `quality-baseline.json` (+`zizmorFindings {down}` — começar advisory, congelar baseline, depois bloquear).
|
||||
- **Approach:** actionlint = pass/fail imediato (sintaxe não tem "legado aceitável"); zizmor = catraca `down` no padrão do motor (os findings atuais — provavelmente actions não-pinadas por SHA — são dívida congelada que decai).
|
||||
- **Acceptance:** workflow novo com `pull_request_target` + checkout de código do PR falha; action não-pinada NOVA sobe o count e falha.
|
||||
|
||||
### Task 20 — License compliance (allowlist SPDX) ➕ *adicionada pela auditoria 6A*
|
||||
- **Tool:** license-compliance ou @onebeyond/license-checker (ambos OSS, npm). Projeto é **MIT** — deps com copyleft forte (GPL/AGPL) em produção são risco de compliance para os usuários do proxy.
|
||||
- **Files:** `scripts/check/check-licenses.mjs`, `.license-allowlist.json` (SPDX permitidas: MIT, Apache-2.0, BSD-2/3, ISC, 0BSD, …), `ci.yml` (lint job), `dependency-allowlist.json`.
|
||||
- **Approach:** rodar sobre as `dependencies` de produção (devDependencies = relatório advisory); licença fora da allowlist → fail com o caminho da dep. Exceções pontuais (dual-license, LGPL avaliada) entram na allowlist por **pacote** com justificativa — pareia com o `check-deps` da Fase 2 (lá controla O QUE entra; aqui, SOB QUAL licença).
|
||||
- **Acceptance:** dep GPL-3.0 sintética em fixture falha; árvore atual passa com a allowlist calibrada.
|
||||
|
||||
---
|
||||
|
||||
## Wiring & CI (resumo)
|
||||
- **lint job:** check-lockfile, check-cognitive-complexity (rápido?), check-type-coverage, **check-licenses (Task 20)**, **actionlint (Task 19)**.
|
||||
- **quality-gate job (paralelo):** check-vuln-ratchet, check-dead-code, check-codeql-ratchet, check-cognitive-complexity (se lento), check-bundle-size, **check-secrets (Task 18)**, **zizmor (Task 19)**.
|
||||
- **pr-test-policy job:** check-pr-evidence.
|
||||
- **sonarqube job:** Clean-as-You-Code + `qualitygate.wait`.
|
||||
- **NIGHTLY job (novo):** Stryker (mutação), semcheck (advisory), a11y full.
|
||||
- Todas as métricas numéricas → `quality-baseline.json` (motor da Fase 1, com `eps`/`tightenSlack` da 6A.5). Toda dep nova → `dependency-allowlist.json`. Toda allowlist nova nasce com o stale-enforcement da 6A.3.
|
||||
|
||||
## Self-Review
|
||||
- **Cobertura do spec:** 7 gates sugeridos = Task 1-3 (segurança), 4 (knip), 9 (coverage por módulo), 10 (evidence), 11 (mutação), 12 (bundle), 13 (a11y). "Todas as ferramentas discutidas" = Tasks 1-8, 11-17 (Sonar/osv/CodeQL/knip/sonarjs/type-coverage/lockfile/dpdm/stryker/size-limit/axe/semcheck/agent-lsp/Qlty). Auditoria 6A (2026-06-09) acrescentou Tasks 18-20 (gitleaks, actionlint+zizmor, license compliance). ✓
|
||||
- **Community/OSS only:** confirmado — Sonar Community Build, todos os demais OSS (gitleaks MIT, zizmor/actionlint OSS, license-compliance npm), zero SaaS pago. ✓
|
||||
- **Sem flag-day:** toda catraca é só-regressão, calibrada do estado atual (zizmor/gitleaks começam advisory→baseline→bloqueio). ✓
|
||||
- **Consistência:** todas as métricas usam o formato `{value, direction}` do motor da Fase 1; deps novas passam pelo `check-deps`+`dependency-allowlist.json`. ✓
|
||||
- **Não-sobreposição com a 6A:** a 6A conserta/endurece o EXISTENTE (runners, stale-allowlists, escopos); a Fase 7 só adiciona ferramenta nova. gitleaks (Task 18) complementa — não substitui — o check-public-creds expandido pela 6A.8. ✓
|
||||
|
||||
## Handoff (na ativação, 2026-06-16+)
|
||||
**Ordem: Fase 6A primeiro** ([`PLANO-QUALITY-GATES-FASE6A.md`](./PLANO-QUALITY-GATES-FASE6A.md)) — consertar os runners (testes órfãos + vitest no CI) e endurecer as catracas existentes muda os baselines (cobertura recalibrada) sobre os quais várias tasks daqui (1, 9, 11) calibram. Depois: começar pela **Task 1-3 (catraca de segurança)**, **Task 4 (knip)** e **Task 19 (zizmor — protege os release-workflows)** — maior retorno. Cada Task vira um sub-plano `writing-plans` bite-sized próprio. Recomendado: Subagent-Driven, 1 subagente por Task, com auditoria (trust-but-verify) e o ratchet `eslintWarnings`/`check-deps` validando que cada adição não regride o que já temos.
|
||||
@@ -1,688 +0,0 @@
|
||||
# Plano de Implementação — Quality Gates & Catraca Anti-Alucinação
|
||||
|
||||
> **Para workers agênticos:** SUB-SKILL OBRIGATÓRIA: use `superpowers:subagent-driven-development` (recomendado) ou `superpowers:executing-plans` para executar este plano tarefa-a-tarefa. Os passos usam checkbox (`- [ ]`) para rastreio. **Cada fix de bug obedece à Hard Rule #18** (teste falha→passa OU validação ao vivo no VPS). Não burle Husky (`--no-verify`) sem aprovação. Veja o diagnóstico completo em [`RELATORIO-QUALITY-GATES.md`](./RELATORIO-QUALITY-GATES.md).
|
||||
|
||||
**Goal:** Generalizar a catraca de qualidade do OmniRoute (hoje só para `any`) para todas as métricas relevantes — cobertura, duplicação, tamanho de arquivo, complexidade — e adicionar gates determinísticos anti-alucinação, tudo no padrão `check-*.mjs` já existente, sem SaaS novo.
|
||||
|
||||
**Architecture:** Camadas incrementais. (0) reativa/reconcilia o que já existe; (1) constrói o **motor de catraca** (`quality-baseline.json` commitado + coletor + comparador genérico que clona o `check-t11-any-budget.mjs`); (2) adiciona gates determinísticos que matam os ímãs de alucinação (provider-consistency, fetch-targets, openapi-routes); (3) catraca de duplicação+tamanho; (4) catraca de cobertura + anti test-masking; (5) skill `/babysit` + evidência. Toda catraca é **só-regressão** (baseline congelado) — nunca um piso absoluto que exija limpeza flag-day.
|
||||
|
||||
**Tech Stack:** Node ≥20 ESM (`.mjs`/`.ts` via `tsx`), ESLint 9 flat config, c8, jscpd v5, eslint-plugin-sonarjs v4, GitHub Actions, Node native test runner (`node --import tsx --test`), `gh` CLI + GraphQL.
|
||||
|
||||
**Escopo / sub-planos (scope-check):** Fases 0, 1 e 2 estão totalmente bite-sized aqui. Fases 3, 4 e 5 são subsistemas independentes — cada uma deve ser **expandida no próprio sub-plano** (via `writing-plans`) no momento da execução, a partir das specs/critérios de aceitação definidos aqui. Cada fase entrega software funcional e testável por si só.
|
||||
|
||||
---
|
||||
|
||||
## Mapa de arquivos (o que será criado/modificado)
|
||||
|
||||
| Arquivo | Responsabilidade |
|
||||
|---------|------------------|
|
||||
| `quality-baseline.json` (criar, **commitar**) | Baseline congelado: por métrica `{value, direction}` (`down`=menor-é-melhor, `up`=maior-é-melhor) |
|
||||
| `scripts/quality/collect-metrics.mjs` (criar) | Roda os coletores → emite `quality-metrics.json` |
|
||||
| `scripts/quality/check-quality-ratchet.mjs` (criar) | Comparador genérico: falha em qualquer regressão; com `--update` ratcheta o baseline |
|
||||
| `scripts/check/check-fetch-targets.mjs` (criar) | Todo `fetch("/api/...")` do dashboard resolve para um `route.ts` real |
|
||||
| `scripts/check/check-provider-consistency.ts` (criar) | ids de provider batem entre `providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts` |
|
||||
| `scripts/check/check-openapi-routes.mjs` (criar) | Toda `path` do `openapi.yaml` ↔ `route.ts` real (bidirecional) |
|
||||
| `scripts/check/check-deps.mjs` (criar) | Anti-slopsquatting: allowlist + existência no registry + age-cooldown |
|
||||
| `.github/workflows/ci.yml` (modificar) | Novo job `quality-gate`; reconciliar gate de cobertura; escalonar audit; plugar scripts órfãos |
|
||||
| `.husky/pre-commit` (modificar) | Reativar a parte barata |
|
||||
| `package.json` (modificar) | Novos scripts `check:*` / `quality:*` |
|
||||
| `eslint.config.mjs` (modificar, Fase 3) | `max-lines`, `max-lines-per-function`, `complexity`, `sonarjs/cognitive-complexity` (warn) |
|
||||
| `.claude/skills/babysit/SKILL.md` (criar, Fase 5) | Skill `/babysit` |
|
||||
| `tests/unit/quality-ratchet.test.ts` etc. (criar) | Testes TDD de cada gate |
|
||||
|
||||
---
|
||||
|
||||
# FASE 0 — Reativar & Reconciliar (quick wins, sem tooling novo)
|
||||
|
||||
### Task 0.1: Reconciliar o gate de cobertura do CI (40 → baseline real)
|
||||
|
||||
**Contexto:** `ci.yml:377` gata em `40/40/40/40`; local gata `60`; comentário renderiza `60`; baseline real ≈ 79–82%. O 40 torna o gate quase banguela.
|
||||
|
||||
**Files:**
|
||||
- Modify: `.github/workflows/ci.yml:376-377`
|
||||
|
||||
- [ ] **Step 1: Confirmar o baseline real de cobertura**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
npm run test:coverage 2>&1 | tail -20
|
||||
node -e "const c=require('./coverage/coverage-summary.json').total; console.log(c.statements.pct,c.lines.pct,c.functions.pct,c.branches.pct)"
|
||||
```
|
||||
Expected: 4 números (ex.: `79.8 79.8 82.2 75.2`). Anote-os.
|
||||
|
||||
- [ ] **Step 2: Subir o gate do CI para `baseline_real - 2` (headroom anti-flake)**
|
||||
|
||||
Em `.github/workflows/ci.yml`, troque a linha `--statements 40 --lines 40 --functions 40 --branches 40` pelos valores `(real-2)` de cada métrica (ex.: `--statements 77 --lines 77 --functions 80 --branches 73`). Mantenha como **piso**; a catraca da Fase 4 cuidará do "não-cair".
|
||||
|
||||
- [ ] **Step 3: Alinhar o script local e o display do comentário** para os mesmos números (procure `60` em `package.json` `test:coverage` e em `scripts/check/test-report-summary.mjs`).
|
||||
|
||||
- [ ] **Step 4: Verificar que o CI não quebra** — abrir um PR de teste (ou rodar `act`/push numa branch) e confirmar que o job `test-coverage` fica verde com o novo piso.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml package.json scripts/check/test-report-summary.mjs
|
||||
git commit -m "fix(ci): reconcile coverage gate to real baseline (40->~78) across CI/local/report"
|
||||
```
|
||||
|
||||
### Task 0.2: Escalonar `npm audit` (critical=bloqueia / high=avisa)
|
||||
|
||||
**Files:** Modify: `package.json:112`
|
||||
|
||||
- [ ] **Step 1: Trocar o script `audit:deps`** de `npm audit --audit-level=moderate && npm run audit:electron` para:
|
||||
```json
|
||||
"audit:deps": "npm audit --audit-level=critical && (npm audit --audit-level=high || echo '::warning::high-severity advisories present (non-blocking)') && npm run audit:electron",
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e observar o comportamento**
|
||||
```bash
|
||||
npm run audit:deps; echo "exit=$?"
|
||||
```
|
||||
Expected: `exit=0` se não houver critical; mensagem de warning se houver high.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add package.json && git commit -m "chore(ci): tier npm audit (critical blocks, high warns)"
|
||||
```
|
||||
|
||||
### Task 0.3: Plugar os 3 scripts órfãos no CI
|
||||
|
||||
**Contexto:** `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17).
|
||||
|
||||
**Files:** Modify: `.github/workflows/ci.yml` (job `docs-sync-strict` ou `lint`)
|
||||
|
||||
- [ ] **Step 1: Rodar os 3 localmente para confirmar verde no estado atual**
|
||||
```bash
|
||||
npm run check:cli-i18n && npm run check:openapi-coverage && npm run check:openapi-security-tiers; echo "exit=$?"
|
||||
```
|
||||
Expected: `exit=0` (se algum falhar, corrija a deriva antes de plugar).
|
||||
|
||||
- [ ] **Step 2: Adicionar os 3 como steps** no job `docs-sync-strict` do `ci.yml`, após `check:docs-all`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml && git commit -m "ci: wire orphaned gates (cli-i18n, openapi-coverage, openapi-security-tiers)"
|
||||
```
|
||||
|
||||
### Task 0.4: Reativar a parte barata do pre-commit do Husky
|
||||
|
||||
**Files:** Modify: `.husky/pre-commit`
|
||||
|
||||
- [ ] **Step 1: Descomentar SÓ as 3 linhas baratas e determinísticas:**
|
||||
```sh
|
||||
npx lint-staged
|
||||
node scripts/check/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
```
|
||||
(Deixe i18n/openapi comentados por enquanto — eles são mais lentos; rodam no CI.)
|
||||
|
||||
- [ ] **Step 2: Testar o hook** com um commit trivial e medir o tempo:
|
||||
```bash
|
||||
time git commit --allow-empty -m "chore: test pre-commit hook"
|
||||
git reset --soft HEAD~1
|
||||
```
|
||||
Expected: hook roda lint-staged + 2 checks em poucos segundos.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add .husky/pre-commit && git commit -m "chore(husky): re-enable cheap pre-commit gates (lint-staged, docs-sync, any-budget)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# FASE 1 — Motor de Catraca (o coração) ⭐
|
||||
|
||||
> Generaliza o `check-t11-any-budget.mjs` (catraca de `any` por arquivo) para um motor de catraca genérico, multi-métrica, que lê um baseline commitado e falha em qualquer regressão. Começa com 2 métricas (warnings de ESLint + cobertura) e é estendido nas fases seguintes.
|
||||
|
||||
### Task 1.1: Comparador genérico de catraca (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/quality/check-quality-ratchet.mjs`
|
||||
- Test: `tests/unit/quality-ratchet.test.ts`
|
||||
|
||||
- [ ] **Step 1: Escrever o teste que falha**
|
||||
```ts
|
||||
// tests/unit/quality-ratchet.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const SCRIPT = path.resolve("scripts/quality/check-quality-ratchet.mjs");
|
||||
|
||||
function run(baseline, metrics, extraArgs = []) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ratchet-"));
|
||||
const bPath = path.join(dir, "baseline.json");
|
||||
const mPath = path.join(dir, "metrics.json");
|
||||
fs.writeFileSync(bPath, JSON.stringify(baseline));
|
||||
fs.writeFileSync(mPath, JSON.stringify(metrics));
|
||||
try {
|
||||
const out = execFileSync("node", [SCRIPT, "--baseline", bPath, "--metrics", mPath, ...extraArgs], { encoding: "utf8" });
|
||||
return { code: 0, out, dir, bPath };
|
||||
} catch (e) {
|
||||
return { code: e.status, out: (e.stdout || "") + (e.stderr || ""), dir, bPath };
|
||||
}
|
||||
}
|
||||
|
||||
test("passes when metrics equal baseline", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" }, "coverage.lines": { value: 80, direction: "up" } } };
|
||||
assert.equal(run(b, { eslintWarnings: 100, "coverage.lines": 80 }).code, 0);
|
||||
});
|
||||
|
||||
test("fails when a 'down' metric regresses (more warnings)", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
const r = run(b, { eslintWarnings: 101 });
|
||||
assert.equal(r.code, 1);
|
||||
assert.match(r.out, /eslintWarnings/);
|
||||
});
|
||||
|
||||
test("fails when an 'up' metric regresses (coverage drops)", () => {
|
||||
const b = { metrics: { "coverage.lines": { value: 80, direction: "up" } } };
|
||||
assert.equal(run(b, { "coverage.lines": 79 }).code, 1);
|
||||
});
|
||||
|
||||
test("passes on improvement; --update ratchets the baseline", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
const r = run(b, { eslintWarnings: 90 }, ["--update"]);
|
||||
assert.equal(r.code, 0);
|
||||
const updated = JSON.parse(fs.readFileSync(r.bPath, "utf8"));
|
||||
assert.equal(updated.metrics.eslintWarnings.value, 90);
|
||||
});
|
||||
|
||||
test("fails (code 2) when a baseline metric is missing from collected metrics", () => {
|
||||
const b = { metrics: { eslintWarnings: { value: 100, direction: "down" } } };
|
||||
assert.equal(run(b, {}).code, 1);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar o teste e ver falhar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/quality-ratchet.test.ts
|
||||
```
|
||||
Expected: FAIL (script não existe ainda).
|
||||
|
||||
- [ ] **Step 3: Implementar o comparador**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/check-quality-ratchet.mjs
|
||||
// Catraca genérica multi-métrica. Clona o espírito de check-t11-any-budget.mjs:
|
||||
// um baseline congelado por métrica; falha em qualquer regressão; só anda num sentido.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
function getArg(name, fallback) {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i >= 0 && process.argv[i + 1] ? process.argv[i + 1] : fallback;
|
||||
}
|
||||
const BASELINE = path.resolve(getArg("--baseline", path.join(cwd, "quality-baseline.json")));
|
||||
const METRICS = path.resolve(getArg("--metrics", path.join(cwd, "quality-metrics.json")));
|
||||
const SUMMARY = getArg("--summary", null);
|
||||
const UPDATE = process.argv.includes("--update");
|
||||
const EPS = 0.01;
|
||||
|
||||
function load(p) {
|
||||
if (!fs.existsSync(p)) { console.error(`[quality-ratchet] arquivo ausente: ${p}`); process.exit(2); }
|
||||
return JSON.parse(fs.readFileSync(p, "utf8"));
|
||||
}
|
||||
|
||||
const baseline = load(BASELINE);
|
||||
const metrics = load(METRICS);
|
||||
const failures = [];
|
||||
const improvements = [];
|
||||
const rows = [];
|
||||
|
||||
for (const [key, spec] of Object.entries(baseline.metrics)) {
|
||||
const current = metrics[key];
|
||||
const base = spec.value;
|
||||
const dir = spec.direction; // "down" = menor-é-melhor | "up" = maior-é-melhor
|
||||
if (current === undefined) { failures.push(`métrica "${key}" ausente em ${path.basename(METRICS)}`); rows.push([key, base, "—", "MISSING"]); continue; }
|
||||
let status = "ok";
|
||||
if (dir === "down") {
|
||||
if (current > base + EPS) { failures.push(`${key}: ${current} > baseline ${base} (não pode aumentar)`); status = "REGRESSÃO"; }
|
||||
else if (current < base - EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
|
||||
} else {
|
||||
if (current < base - EPS) { failures.push(`${key}: ${current} < baseline ${base} (não pode cair)`); status = "REGRESSÃO"; }
|
||||
else if (current > base + EPS) { improvements.push([key, current]); status = "↑ melhorou"; }
|
||||
}
|
||||
rows.push([key, base, current, status]);
|
||||
}
|
||||
|
||||
if (SUMMARY) {
|
||||
const md = ["# Quality Ratchet", "", "| Métrica | Baseline | Atual | Status |", "|---|---|---|---|",
|
||||
...rows.map(([k, b, c, s]) => `| ${k} | ${b} | ${c} | ${s} |`), "",
|
||||
failures.length ? `**${failures.length} regressão(ões) — gate BLOQUEADO.**` : "**Sem regressões — gate OK.**"].join("\n");
|
||||
fs.mkdirSync(path.dirname(SUMMARY), { recursive: true });
|
||||
fs.writeFileSync(SUMMARY, md + "\n");
|
||||
}
|
||||
|
||||
if (UPDATE && failures.length === 0 && improvements.length) {
|
||||
for (const [key, val] of improvements) baseline.metrics[key].value = val;
|
||||
fs.writeFileSync(BASELINE, JSON.stringify(baseline, null, 2) + "\n");
|
||||
console.log(`[quality-ratchet] baseline ratcheado: ${improvements.length} métrica(s) melhoraram`);
|
||||
}
|
||||
|
||||
if (failures.length) { console.error("[quality-ratchet] FALHOU:\n" + failures.map((f) => " ✗ " + f).join("\n")); process.exit(1); }
|
||||
console.log(`[quality-ratchet] OK (${rows.length} métricas, ${improvements.length} melhoraram)`);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rodar o teste e ver passar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/quality-ratchet.test.ts
|
||||
```
|
||||
Expected: PASS (5/5).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add scripts/quality/check-quality-ratchet.mjs tests/unit/quality-ratchet.test.ts
|
||||
git commit -m "feat(quality): generic ratchet comparator (multi-metric, regression-only)"
|
||||
```
|
||||
|
||||
### Task 1.2: Coletor de métricas (ESLint warnings + cobertura)
|
||||
|
||||
**Files:** Create: `scripts/quality/collect-metrics.mjs`
|
||||
|
||||
- [ ] **Step 1: Implementar o coletor**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/quality/collect-metrics.mjs — emite quality-metrics.json
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const out = {};
|
||||
|
||||
// 1) ESLint: contagem de warnings (errors devem ser 0; o lint já gata isso)
|
||||
function eslintCounts() {
|
||||
let stdout;
|
||||
try {
|
||||
stdout = execFileSync("npx", ["eslint", ".", "--format", "json"], { encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
|
||||
} catch (e) { stdout = e.stdout?.toString() || "[]"; } // eslint sai !=0 quando há errors
|
||||
const results = JSON.parse(stdout);
|
||||
out.eslintWarnings = results.reduce((n, r) => n + (r.warningCount || 0), 0);
|
||||
out.eslintErrors = results.reduce((n, r) => n + (r.errorCount || 0), 0);
|
||||
}
|
||||
|
||||
// 2) Cobertura: lê coverage/coverage-summary.json se existir
|
||||
function coverage() {
|
||||
const p = path.join(cwd, "coverage", "coverage-summary.json");
|
||||
if (!fs.existsSync(p)) return;
|
||||
const t = JSON.parse(fs.readFileSync(p, "utf8")).total;
|
||||
out["coverage.statements"] = t.statements.pct;
|
||||
out["coverage.lines"] = t.lines.pct;
|
||||
out["coverage.functions"] = t.functions.pct;
|
||||
out["coverage.branches"] = t.branches.pct;
|
||||
}
|
||||
|
||||
eslintCounts();
|
||||
coverage();
|
||||
fs.writeFileSync(path.join(cwd, "quality-metrics.json"), JSON.stringify(out, null, 2) + "\n");
|
||||
console.log("[collect-metrics]", JSON.stringify(out));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e inspecionar a saída**
|
||||
```bash
|
||||
node scripts/quality/collect-metrics.mjs && cat quality-metrics.json
|
||||
```
|
||||
Expected: JSON com `eslintWarnings`, `eslintErrors` e (se houver coverage) os 4 `coverage.*`. **Anote `eslintWarnings`.**
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
```bash
|
||||
git add scripts/quality/collect-metrics.mjs && echo "quality-metrics.json" >> .gitignore
|
||||
git add .gitignore && git commit -m "feat(quality): metrics collector (eslint warnings + coverage)"
|
||||
```
|
||||
|
||||
### Task 1.3: Congelar o baseline inicial
|
||||
|
||||
**Files:** Create: `quality-baseline.json` (**commitado**)
|
||||
|
||||
- [ ] **Step 1: Gerar o baseline a partir das métricas reais** (use os números anotados):
|
||||
```json
|
||||
{
|
||||
"_comment": "Catraca: 'down' nao pode aumentar, 'up' nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (so em melhora).",
|
||||
"metrics": {
|
||||
"eslintWarnings": { "value": <N_REAL>, "direction": "down" },
|
||||
"coverage.statements": { "value": <S>, "direction": "up" },
|
||||
"coverage.lines": { "value": <L>, "direction": "up" },
|
||||
"coverage.functions": { "value": <F>, "direction": "up" },
|
||||
"coverage.branches": { "value": <B>, "direction": "up" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Validar a catraca contra si mesma**
|
||||
```bash
|
||||
node scripts/quality/collect-metrics.mjs
|
||||
node scripts/quality/check-quality-ratchet.mjs; echo "exit=$?"
|
||||
```
|
||||
Expected: `[quality-ratchet] OK` e `exit=0`.
|
||||
|
||||
- [ ] **Step 3: Provar que pega regressão** (teste manual): edite `quality-metrics.json` somando 1 a `eslintWarnings`, rode o comparador, confirme `exit=1`, depois descarte a edição.
|
||||
|
||||
- [ ] **Step 4: Adicionar scripts npm**
|
||||
```json
|
||||
"quality:collect": "node scripts/quality/collect-metrics.mjs",
|
||||
"quality:ratchet": "node scripts/quality/check-quality-ratchet.mjs",
|
||||
"quality:gate": "npm run quality:collect && npm run quality:ratchet"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
```bash
|
||||
git add quality-baseline.json package.json
|
||||
git commit -m "feat(quality): freeze initial quality baseline (eslint warnings + coverage)"
|
||||
```
|
||||
|
||||
### Task 1.4: Wire no CI (job + artefato + comentário no PR)
|
||||
|
||||
**Files:** Modify: `.github/workflows/ci.yml`
|
||||
|
||||
- [ ] **Step 1: Adicionar job `quality-gate`** (depois de `test-coverage`, para reusar `coverage/coverage-summary.json`):
|
||||
```yaml
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-coverage
|
||||
if: ${{ always() && needs.test-coverage.result == 'success' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: '24', cache: 'npm' }
|
||||
- run: npm ci
|
||||
- uses: actions/download-artifact@v4
|
||||
with: { name: coverage-report, path: coverage/ }
|
||||
- run: npm run quality:collect
|
||||
- run: node scripts/quality/check-quality-ratchet.mjs --summary .artifacts/quality-ratchet.md
|
||||
- if: always()
|
||||
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY"
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with: { name: quality-ratchet, path: .artifacts/quality-ratchet.md }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Adicionar comentário no PR** clonando o job `coverage-pr-comment` (marcador `<!-- omniroute-quality-ratchet -->`, lê `.artifacts/quality-ratchet.md`). Reuse o mesmo `github-script` de upsert de comentário.
|
||||
|
||||
- [ ] **Step 3: Validar num PR de teste** — confirmar que o job aparece, o step summary mostra a tabela, e o comentário é postado.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
```bash
|
||||
git add .github/workflows/ci.yml
|
||||
git commit -m "ci(quality): add quality-ratchet job with PR comment + artifact"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# FASE 2 — Gates Determinísticos Anti-Alucinação
|
||||
|
||||
> Cada gate ataca um ímã de alucinação específico (ver §2.4 do relatório). Todos no padrão `check-*.mjs`. **`check-fetch-targets` vem com código completo** (parsing determinístico de arquivos, sem risco de inventar nomes de export). **`check-provider-consistency` e `check-openapi-routes` começam com um Step 0 de verificação dos nomes reais** — propositalmente, porque fabricar a forma do import/spec seria o exato anti-padrão que este plano combate.
|
||||
|
||||
### Task 2.1: `check-fetch-targets.mjs` — toda rota chamada pelo dashboard existe (TDD)
|
||||
|
||||
**Ataca:** ímã nº2 (300 paths `fetch("/api/...")` sem ligação com as rotas).
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/check/check-fetch-targets.mjs`
|
||||
- Test: `tests/unit/check-fetch-targets.test.ts`
|
||||
|
||||
- [ ] **Step 1: Escrever o teste que falha**
|
||||
```ts
|
||||
// tests/unit/check-fetch-targets.test.ts
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { resolveApiPathToRoute } from "../../scripts/check/check-fetch-targets.mjs";
|
||||
|
||||
test("matches a static route file", () => {
|
||||
const files = new Set(["src/app/api/usage/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/usage", files), true);
|
||||
});
|
||||
|
||||
test("matches a dynamic [param] segment", () => {
|
||||
const files = new Set(["src/app/api/providers/[id]/models/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/providers/abc-123/models", files), true);
|
||||
});
|
||||
|
||||
test("rejects a hallucinated route", () => {
|
||||
const files = new Set(["src/app/api/usage/route.ts"]);
|
||||
assert.equal(resolveApiPathToRoute("/api/providers/refresh", files), false);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rodar e ver falhar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/check-fetch-targets.test.ts
|
||||
```
|
||||
Expected: FAIL (módulo não existe).
|
||||
|
||||
- [ ] **Step 3: Implementar o gate**
|
||||
```js
|
||||
#!/usr/bin/env node
|
||||
// scripts/check/check-fetch-targets.mjs
|
||||
// Todo fetch("/api/...") em src/app/(dashboard) deve resolver para um route.ts real.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const cwd = process.cwd();
|
||||
const DASH = path.join(cwd, "src/app/(dashboard)");
|
||||
const API = path.join(cwd, "src/app/api");
|
||||
|
||||
// allowlist de paths dinâmicos/externos que o checker não consegue resolver estaticamente
|
||||
const IGNORE = [/^\/api\/v1\//, /\$\{/, /` \+/]; // /v1 é a superfície OpenAI-compat; templates
|
||||
|
||||
function walk(dir, acc = []) {
|
||||
if (!fs.existsSync(dir)) return acc;
|
||||
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) walk(p, acc);
|
||||
else if (/\.(ts|tsx)$/.test(e.name)) acc.push(p);
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
function collectRouteFiles() {
|
||||
return new Set(walk(API).filter((p) => /route\.tsx?$/.test(p)).map((p) => path.relative(cwd, p).replace(/\\/g, "/")));
|
||||
}
|
||||
|
||||
export function resolveApiPathToRoute(apiPath, routeFiles) {
|
||||
// apiPath ex.: /api/providers/abc/models → src/app/api/providers/[id]/models/route.ts
|
||||
const segs = apiPath.replace(/^\//, "").replace(/[?#].*$/, "").split("/"); // ["api","providers","abc","models"]
|
||||
for (const rf of routeFiles) {
|
||||
const rsegs = rf.replace(/^src\/app\//, "").replace(/\/route\.tsx?$/, "").split("/"); // ["api","providers","[id]","models"]
|
||||
if (rsegs.length !== segs.length) continue;
|
||||
const ok = rsegs.every((rs, i) => rs === segs[i] || /^\[.*\]$/.test(rs));
|
||||
if (ok) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function extractFetchPaths(file) {
|
||||
const src = fs.readFileSync(file, "utf8");
|
||||
const re = /(?:fetch|fetchJson|apiFetch)\(\s*["'`](\/api\/[^"'`?]+)/g;
|
||||
const out = [];
|
||||
let m;
|
||||
while ((m = re.exec(src))) out.push(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const routeFiles = collectRouteFiles();
|
||||
const misses = [];
|
||||
for (const f of walk(DASH)) {
|
||||
for (const apiPath of extractFetchPaths(f)) {
|
||||
if (IGNORE.some((rx) => rx.test(apiPath))) continue;
|
||||
if (!resolveApiPathToRoute(apiPath, routeFiles)) misses.push(`${path.relative(cwd, f)} → ${apiPath}`);
|
||||
}
|
||||
}
|
||||
if (misses.length) {
|
||||
console.error(`[check-fetch-targets] ${misses.length} fetch(es) para rota inexistente:\n` + misses.map((m) => " ✗ " + m).join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`[check-fetch-targets] OK (${routeFiles.size} rotas conhecidas)`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) main();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Rodar o teste unitário e ver passar**
|
||||
```bash
|
||||
node --import tsx --test tests/unit/check-fetch-targets.test.ts
|
||||
```
|
||||
Expected: PASS (3/3).
|
||||
|
||||
- [ ] **Step 5: Rodar o gate no repo real** (modo descoberta — pode encontrar misses legítimos)
|
||||
```bash
|
||||
node scripts/check/check-fetch-targets.mjs; echo "exit=$?"
|
||||
```
|
||||
Se encontrar misses reais: triagem — ou são rotas faltantes (bug), ou paths dinâmicos a adicionar ao `IGNORE`. **Não** mascare; documente cada exceção no `IGNORE` com comentário.
|
||||
|
||||
- [ ] **Step 6: Adicionar script + commit**
|
||||
```json
|
||||
"check:fetch-targets": "node scripts/check/check-fetch-targets.mjs"
|
||||
```
|
||||
```bash
|
||||
git add scripts/check/check-fetch-targets.mjs tests/unit/check-fetch-targets.test.ts package.json
|
||||
git commit -m "feat(check): gate that every dashboard fetch() targets a real API route"
|
||||
```
|
||||
|
||||
### Task 2.2: `check-provider-consistency.ts` — ids batem entre os 3 arquivos
|
||||
|
||||
**Ataca:** ímã nº1 (split de provider; 229 vs 155 vs N já divergem).
|
||||
|
||||
**Files:** Create: `scripts/check/check-provider-consistency.ts` (rodado via `tsx`); Test: `tests/unit/check-provider-consistency.test.ts`
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO — obrigatório antes de codar):** descobrir os nomes reais de export, para **não inventar**:
|
||||
```bash
|
||||
grep -nE "export (const|function) " src/shared/constants/providers.ts | grep -iE "provider|section" | head
|
||||
grep -nE "getOrCreateAiProviders|_PROVIDER_SECTIONS|AI_PROVIDERS" src/shared/constants/providers.ts | head
|
||||
grep -nE "baseUrl|^\s*['\"][a-z0-9-]+['\"]\s*:" open-sse/config/providerRegistry.ts | head
|
||||
grep -nE "case ['\"]|=== ['\"]|SPECIALTY_VALIDATORS" src/lib/providers/validation.ts | head
|
||||
```
|
||||
Anote: como enumerar os ids canônicos em runtime, a forma das chaves do `providerRegistry.ts`, e onde `validation.ts` lista os providers cobertos.
|
||||
|
||||
- [ ] **Step 1: Escrever o teste** com fixtures sintéticas (3 conjuntos de ids), afirmando que o diff detecta um id presente em um e ausente em outro. (Implemente a lógica como função pura `diffProviderSets(canonical, registry, validators)` que retorna `{missingInRegistry, missingInValidators, orphanInRegistry}`.)
|
||||
|
||||
- [ ] **Step 2: Rodar e ver falhar.**
|
||||
|
||||
- [ ] **Step 3: Implementar** importando os ids reais (descobertos no Step 0) e cruzando-os. Reusar o padrão de `scripts/check/check-docs-counts-sync.mjs` (que **já conta** executors/oauth/strategies vs docs — é o template direto). Para ids que legitimamente vivem só num lugar, manter um `KNOWN_EXCEPTIONS` allowlist com comentário por entrada.
|
||||
|
||||
- [ ] **Step 4: Rodar no repo real**, triar as divergências reais (as contagens já divergem — algumas serão bugs de registro pela metade, outras exceções legítimas).
|
||||
|
||||
- [ ] **Step 5: Commit** (`feat(check): provider-id consistency gate across providers.ts/registry/validation`).
|
||||
|
||||
**Critério de aceitação:** o gate falha quando um id existe em `providers.ts` mas não no `providerRegistry.ts` (ou vice-versa), e quando um model no registry referencia um provider id desconhecido.
|
||||
|
||||
### Task 2.3: `check-openapi-routes.mjs` — spec ↔ rotas (bidirecional)
|
||||
|
||||
**Ataca:** docs-hallucination (endpoint inventado no `openapi.yaml`).
|
||||
|
||||
**Files:** Create: `scripts/check/check-openapi-routes.mjs`; Test correspondente.
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO):** confirmar o caminho e a forma do spec:
|
||||
```bash
|
||||
ls docs/reference/openapi.yaml && grep -nE "^\s{2,4}/[a-z]" docs/reference/openapi.yaml | head
|
||||
```
|
||||
Confirmar como `check-openapi-coverage.mjs` já parseia o YAML (reusar o parser dele).
|
||||
|
||||
- [ ] **Step 1–5 (TDD):** teste com spec sintética → implementar: toda `path` do spec resolve para um `route.ts` (reusando `resolveApiPathToRoute` da Task 2.1) e toda rota não-interna aparece no spec (com allowlist para rotas LOCAL_ONLY/internas). Commit.
|
||||
|
||||
### Task 2.4: `check-deps.mjs` — anti-slopsquatting
|
||||
|
||||
**Ataca:** pacotes alucinados (CSA: 19,7% das amostras IA).
|
||||
|
||||
**Files:** Create: `scripts/check/check-deps.mjs`; Test.
|
||||
|
||||
- [ ] **Step 0 (VERIFICAÇÃO):** decidir política — allowlist = todas as deps atuais do `package.json`/lockfile como baseline; novas deps exigem (a) existir no registry, (b) idade ≥ 72h, (c) entrada explícita.
|
||||
- [ ] **Step 1–5 (TDD):** teste com `package.json` sintético adicionando uma dep nova → gate falha se a dep não está no baseline E (não existe no registry OU foi publicada há <72h). Usar `npm view <pkg> time.created` para idade. Reusar o padrão diff-base...HEAD de `check-pr-test-policy.mjs`. Commit.
|
||||
|
||||
### Task 2.5 (opcional): lints Rule #11/#12 via `no-restricted-syntax`
|
||||
|
||||
- [ ] Estender o `eslint.config.mjs` (bloco `no-restricted-syntax` já usado p/ a regra de busca turca) para sinalizar `NextResponse.json({ error: "<string>" })` cru (exigir `buildErrorBody()`) e literais de `clientIdDefault`/`clientSecretDefault` no `providerRegistry.ts` (exigir `resolvePublicCred()`). Ratchet via contagem (adoção 11% → sobe). TDD com fixtures de ESLint.
|
||||
|
||||
**Plugar tudo da Fase 2 no CI:** adicionar `check:fetch-targets`, `check:provider-consistency`, `check:openapi-routes`, `check:deps` ao job `lint` (ou `docs-sync-strict`) do `ci.yml`, e ao pre-commit barato quando rápidos o suficiente.
|
||||
|
||||
---
|
||||
|
||||
# FASE 3 — Catraca de Duplicação + Tamanho (mata-slop) — *expandir em sub-plano*
|
||||
|
||||
**Justificativa:** GitClear mostra duplicação 4–8× na era IA; é a assinatura nº1 de slop. Nenhum gate hoje (Sonar CPD excluído).
|
||||
|
||||
**Specs (criar sub-plano `writing-plans` a partir daqui):**
|
||||
|
||||
1. **Adicionar `jscpd` v5** como devDependency. Rodar `jscpd --reporters json src open-sse` → `quality-metrics.json` ganha `duplication.pct`. **[verificar no install]** o schema JSON do v5 antes de parsear (ver ressalva no relatório §4.2).
|
||||
2. **Estender `collect-metrics.mjs`** com um coletor de duplicação (lê o JSON do jscpd) e um coletor de **tamanho de arquivo** (conta LOC de cada arquivo em `src`+`open-sse`, emite `fileSize.<path>` para os arquivos já acima de um teto, ex. >700 LOC — congelando os 64 atuais).
|
||||
3. **Adicionar regras ESLint** em `eslint.config.mjs` como `warn`: `max-lines` (700), `max-lines-per-function` (80), `complexity` (15), `sonarjs/cognitive-complexity` (15). Coletor adiciona `eslintWarnings` por categoria (a contagem já está na catraca da Fase 1; opcionalmente segregar `complexityWarnings`).
|
||||
4. **Estender `quality-baseline.json`** com `duplication.pct` (`down`) e os `fileSize.*` dos arquivos grandes (`down` — só podem encolher; arquivos novos têm teto absoluto).
|
||||
5. **Teto absoluto para arquivos novos:** o gate falha se um arquivo **não** presente no baseline nasce acima do teto (ex. 700 LOC) — impede o próximo god-component.
|
||||
|
||||
**Critérios de aceitação:** PR que aumenta a duplicação % falha; PR que cresce qualquer um dos 64 arquivos grandes falha; PR que cria arquivo novo >700 LOC falha; refator que encolhe um arquivo grande ratcheta o baseline para baixo (via `--update`).
|
||||
|
||||
---
|
||||
|
||||
# FASE 4 — Catraca de Cobertura + Anti Test-Masking — *expandir em sub-plano*
|
||||
|
||||
**Specs:**
|
||||
|
||||
1. **`check-coverage-ratchet`** já é coberto pelo motor da Fase 1 (as 4 métricas `coverage.*` com `direction: "up"`). Confirmar que o job `quality-gate` roda **após** o merge dos 8 shards de cobertura no CI (não localmente, onde a cobertura é parcial). Adicionar **epsilon** maior (ex. 0.5) para `coverage.branches` por causa do não-determinismo do v8.
|
||||
2. **Pisos por módulo crítico:** estender `collect-metrics.mjs` para emitir `coverage.<modulo>.lines` para uma lista curta de módulos de alto risco (lendo o `coverage-summary.json` por arquivo): `open-sse/handlers/chatCore.ts`, `open-sse/services/combo.ts`, `open-sse/services/accountFallback.ts`, `src/sse/services/auth.ts`, `src/server/authz/routeGuard.ts`, `open-sse/utils/error.ts`, `open-sse/utils/publicCreds.ts`, `src/shared/utils/circuitBreaker.ts`. Cada um vira métrica `up` no baseline (implementa o "risco, não % bruto" do vídeo).
|
||||
3. **`check-test-masking.mjs`** (anti enfraquecimento de asserts): clonar `check-pr-test-policy.mjs` (diff `base...HEAD`); para cada `*.test.ts`/`*.spec.ts` alterado, contar `assert*(`/`expect(` em base vs HEAD; **sinalizar remoção líquida** de asserts para revisão humana. Banir novos `assert.ok(true)`. Heurístico mas alto-sinal — ataca diretamente o risco organizacional nº1 ("subagente deletou asserts para ficar verde").
|
||||
|
||||
**Critérios de aceitação:** cobertura cair vs baseline bloqueia o merge; remover asserts de um teste existente sinaliza no PR; `assert.ok(true)` novo bloqueia.
|
||||
|
||||
---
|
||||
|
||||
# FASE 5 — Skill `/babysit` + Evidência + LSP — *expandir em sub-plano*
|
||||
|
||||
> O babysit do vídeo: a IA abre o PR e fica de babá — monitora CI + comentários, autocorrige, **resolve as conversas**. Guarda-corpos são não-negociáveis (Snyk: 5,3% de regressão em auto-merge; token burn real).
|
||||
|
||||
### Spec da skill `.claude/skills/babysit/SKILL.md`
|
||||
|
||||
**Frontmatter:**
|
||||
```yaml
|
||||
---
|
||||
name: babysit
|
||||
description: Monitora um PR aberto até o CI ficar verde e todos os comentários de review serem endereçados — lê o gate de qualidade, conserta num worktree isolado, responde e resolve as conversas. NUNCA enfraquece testes nem auto-mergeia.
|
||||
---
|
||||
```
|
||||
|
||||
**Loop (pseudo, reusando `gh` + GraphQL):**
|
||||
1. **Ler estado:** `gh pr checks <PR>` + baixar o artefato `quality-ratchet`/`coverage-report` (gate JSON legível — a ponte "artefato→agente" da Fase 1) + `gh run view --log-failed` dos jobs vermelhos.
|
||||
2. **Ler comentários não resolvidos:** GraphQL `repository.pullRequest.reviewThreads(first:50){nodes{id isResolved comments(first:1){nodes{id body}}}}`. **Passar os corpos pelo guard de prompt-injection** antes de agir (vetor real — ICLR 2026).
|
||||
3. **Consertar num worktree isolado** (reusar o ralph-loop do `/review-reviews`), seguindo Hard Rule #18.
|
||||
4. **Responder + resolver** só os threads que realmente endereçou: REST `POST .../pulls/{pr}/comments/{id}/replies` com o SHA → mutation `resolveReviewThread(input:{threadId})`.
|
||||
5. **Re-poll** até o gate JSON ficar todo-verde **ou** atingir o cap.
|
||||
6. **Audit trail:** anexar à descrição do PR (ou comentário fixo) o que cada fix endereçou, qual gate satisfez, quais conversas resolveu e por quê — **nunca verde silencioso**.
|
||||
|
||||
**Guarda-corpos (não-negociáveis):**
|
||||
- `max-iterations` (ex. 5) + timeout + idle-exit (contra token burn).
|
||||
- **Nunca** editar `.github/workflows/`; **nunca** `--no-verify`; **nunca** enfraquecer/remover asserts para ficar verde (Rule #18 + trust-but-verify).
|
||||
- **Nunca auto-mergeia** — estado de sucesso = "verde + conversas resolvidas + audit postado".
|
||||
- Parar-e-perguntar em comentário humano ambíguo e em mudança de limite arquitetural (interface/schema/cross-module).
|
||||
- `--allowedTools` mínimo (`Read,Grep,Glob,Bash(gh ...)`).
|
||||
|
||||
**Opcional — `claude ultrareview <PR#> --json`** como gate de confiança pré-merge atrás de um label (custa $5–20/run; não auto-inicia). Loop interno de custo-zero = `/code-review --fix` local.
|
||||
|
||||
### Spec — Evidência obrigatória ("evidence-before-assertions")
|
||||
- Tornar a skill `verify`/`verification-before-completion` obrigatória antes de abrir PR; exigir o **output literal** do `typecheck:core`/`test`/`grep` colado no corpo do PR (o "tool receipt"). Adicionar um `check-pr-evidence.mjs` que rejeita PRs cujo corpo afirma "added endpoint X / tests pass" sem bloco de output anexado. Formaliza a Rule #18.
|
||||
|
||||
### Spec — LSP-in-the-loop (opcional)
|
||||
- Registrar `agent-lsp` (MCP) ou o `tsserver` para os agentes terem `blast_radius`/diagnostics e `preview_edit` antes de escrever — vira "símbolo inventado" de catch-de-review para impossibilidade-no-edit. Pareia com `typecheck:core` como gate pré-PR (compile-before-claim).
|
||||
|
||||
**Critérios de aceitação:** a skill `/babysit <PR#>` leva um PR de vermelho a verde sem auto-merge, resolve só as conversas que endereçou, respeita o cap de iterações, e deixa rastro auditável; nenhum assert é enfraquecido.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review (checklist do autor)
|
||||
|
||||
- **Cobertura do spec:** Fases 0–2 mapeiam 1:1 com as recomendações do relatório §5; Fases 3–5 cobrem duplicação/tamanho, cobertura/masking e babysit/evidência. ✓
|
||||
- **Sem placeholders nos passos bite-sized:** Fases 0/1/2.1 têm comandos e código reais. As Fases 2.2–2.4 usam um Step-0 de verificação **deliberado** (não placeholder) para não fabricar nomes de export — coerente com o objetivo anti-alucinação. ✓
|
||||
- **Consistência de tipos:** `resolveApiPathToRoute` (Task 2.1) é reusada na Task 2.3; o comparador usa o mesmo formato `{value, direction}` em todas as fases; `quality-metrics.json` é a interface única coletor↔comparador. ✓
|
||||
- **Ordem de dependência:** Fase 1 (motor) precede 3/4 (que só adicionam métricas ao baseline); Fase 0 destrava o resto. ✓
|
||||
|
||||
## Handoff de Execução
|
||||
|
||||
**Plano salvo em `PLANO-QUALITY-GATES.md`.** Duas opções:
|
||||
|
||||
1. **Subagent-Driven (recomendado)** — um subagente fresco por task, review entre tasks. SUB-SKILL: `superpowers:subagent-driven-development`.
|
||||
2. **Inline** — executar nesta sessão com checkpoints. SUB-SKILL: `superpowers:executing-plans`.
|
||||
|
||||
**Recomendação:** começar pela **Fase 0** (quick wins, baixo risco) e **Fase 1** (motor de catraca) numa branch `feat/quality-ratchet`, validar num PR de teste, e só então abrir as fases anti-alucinação. Fases 3–5 viram sub-planos próprios.
|
||||
@@ -1,219 +0,0 @@
|
||||
# Relatório — Quality Gates, Catraca & Anti-Alucinação no OmniRoute
|
||||
|
||||
> **Data:** 2026-06-09
|
||||
> **Origem:** Auditoria do projeto (5 subagentes Opus em paralelo mapeando todas as pastas exceto `node_modules`/`_references`/`dist`) + análise da transcrição do vídeo *"Qualidade de código"* (Stupid Button Club, 2026-05-04) + pesquisa web 2026 (4 frentes, últimos ~3 meses).
|
||||
> **Companheiro:** Veja [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) para o plano de implementação bite-sized (TDD).
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **O OmniRoute já é muito mais maduro** que o projeto "Strawberry" do vídeo: tem CI com 20 jobs, gate de cobertura, ESLint 9 flat, SonarQube, 14 scripts `check-*.mjs` e **uma catraca real já funcionando** (`check-t11-any-budget.mjs` — orçamento de `any` por arquivo que só pode encolher). O vídeo descreve onde queremos chegar; nós já estamos a meio caminho.
|
||||
2. **Mas faltam exatamente as catracas que o vídeo prega.** Não há baseline congelado de métricas, nem gate de **duplicação**, nem de **tamanho de arquivo**, e o gate de cobertura é um **piso fixo** — não uma catraca "nunca piorar".
|
||||
3. **Há derivas (drifts) reais que pegamos na auditoria:** o gate de cobertura **no CI é `40/40/40/40`** (ci.yml:377), não os `60/60/60/60` que o CLAUDE.md anuncia (esse é só o script local). O Husky está **100% comentado** (zero gate local). O SonarQube tem `coverage` e `cpd` **excluídos** (`sonar-project.properties:9-10`) — as duas métricas mais úteis contra "slop" estão desligadas. E 3 scripts `check-*` existem mas **não rodam em lugar nenhum**.
|
||||
4. **Os maiores ímãs de alucinação são estruturais:** o split de provider em 3 arquivos gigantes em 2 workspaces (`providers.ts` ↔ `providerRegistry.ts` ↔ `validation.ts`, com contagens que já divergem: 229 ids vs 155 blocos vs N validadores), os 300 paths `fetch("/api/...")` hardcoded sem ligação de compilação com as rotas, e o arquivo de **12.760 linhas** (`providers/[id]/page.tsx`) que nenhum agente consegue segurar em contexto.
|
||||
5. **2026 confirma a tese do vídeo com dados:** GitClear (211M linhas) mostra duplicação crescendo 4–8× na era da IA; o paper SlopCodeBench prova que *instrução de prompt sozinha não impede a degradação* — só gates determinísticos seguram. O ecossistema 2026 tem ferramentas maduras para cada métrica (jscpd v5, knip v6, eslint-plugin-sonarjs v4, osv-scanner, Qlty, Sonar "Clean as You Code"/"AI Code Assurance").
|
||||
6. **A jogada é em camadas:** (a) reativar/reconciliar o que já existe; (b) construir o **motor de catraca** (baseline.json + coletor + comparador, clonando o `any-budget`); (c) adicionar **gates determinísticos anti-alucinação** (provider-consistency, fetch-target, openapi-routes); (d) catraca de **duplicação + tamanho**; (e) catraca de **cobertura** + detecção de test-masking; (f) skill **`/babysit`** com guarda-corpos. Detalhe no plano.
|
||||
|
||||
---
|
||||
|
||||
## 1. O que o vídeo ensina (insights destilados)
|
||||
|
||||
O vídeo é uma fala sem roteiro sobre *qualidade de código no mundo em que a IA escreve ~100% do código*. Pontos centrais:
|
||||
|
||||
| # | Insight | Citação/essência |
|
||||
|---|---------|------------------|
|
||||
| 1 | **O humano virou o gargalo.** | "Eu acabei virando o gargalo da IA. Fazer o babysit das coisas do request básicas é o gargalo. Não consigo entregar 4 tarefas ao mesmo tempo se preciso ler 10.000 linhas/dia." |
|
||||
| 2 | **Quality Gate = portão que a IA tem que passar.** | Todo PR passa por um portão; a IA fica em loop se autocorrigindo até ficar verde, em vez de o humano revisar e pedir refação. |
|
||||
| 3 | **Baseline + Catraca (ratchet).** | "Tu congela o baseline e o repositório só pode melhorar a partir dali ou empatar." A catraca anda num sentido só. |
|
||||
| 4 | **Regra de ouro.** | "Cada PR pode adicionar código, mas não pode aumentar nenhuma das métricas — nem por uma violação, nem por uma linha, nem por 0,1 ponto percentual." |
|
||||
| 5 | **Métricas do baseline.** | Violações de ESLint (483 em 120 arquivos), duplicação de código (2,2% via JSCPD), cobertura (%), arquivos acima do limite de tamanho (19 arquivos; o maior com 4.600 linhas). |
|
||||
| 6 | **Pipeline de CI.** | `npm ci` → `npm audit` (critical=bloqueia / high=avisa) → `npm run lint` → `test:coverage` → **script node de quality gate** que compara métricas atuais vs `baseline.json` e falha em qualquer regressão → comentário no PR + **upload de artefatos** que o agente lê para se autocorrigir. |
|
||||
| 7 | **Artefatos legíveis pelo agente.** | "Não adianta cuspir isso no PR. O agente precisa ter acesso ao que está dando errado." |
|
||||
| 8 | **Babysit skill.** | "Recomendo criar uma skill de babysit": a IA monitora o CI + comentários dos revisores, endereça os comentários e **resolve as conversas** para dar rastreabilidade no GitHub. |
|
||||
| 9 | **Comentários perto do código (legibilidade p/ agente).** | Mudou de ideia: antes era contra comentários ("o código é a documentação"); agora, no mundo de agentes, comentário explicando *o quê* e *por quê* perto do código vale mais que um MD gigante, porque o harness faz `grep` no arquivo e lê o comentário junto. |
|
||||
| 10 | **É "só" colar ferramentas.** | "Não é nada excepcional. Eu só estou colando um monte de ferramentas e chamando de quality gate." Pode-se usar SonarQube ou GitHub Code Quality no lugar do script caseiro. |
|
||||
| 11 | **Por que a IA não faz certo de primeira.** | Modelos top já sabem (foram treinados com os livros), mas são "preguiçosos" porque output imperfeito = mais tokens vendidos. A catraca força o nível. |
|
||||
|
||||
**Tradução para o nosso contexto:** o vídeo descreve um sistema que **já temos em embrião** (o `any-budget` é exatamente a catraca da regra de ouro, só que para uma métrica). O salto é (1) generalizar a catraca para todas as métricas, (2) reconciliar os drifts, e (3) fechar os buracos anti-alucinação que são específicos do nosso tamanho.
|
||||
|
||||
---
|
||||
|
||||
## 2. Onde o OmniRoute está hoje (panorama auditado)
|
||||
|
||||
### 2.1 O que já temos (e o vídeo nem sonha)
|
||||
|
||||
- **CI robusto** (`.github/workflows/ci.yml`, ~25 KB, 20 jobs): lint + audit + cycles + route-validation + any-budget + docs-sync + typecheck (core e noimplicit) + build + package-artifact + electron-smoke + unit (8 shards) + Node 24/26 compat + coverage (8 shards + merge) + SonarQube + e2e (9 shards) + integration + security.
|
||||
- **Catraca real já existente:** `scripts/check/check-t11-any-budget.mjs` — array `{file, maxAny}` (a maioria `0`), strip de comentários, anotações de falso-positivo, `exit 1` em regressão. **É o template exato da catraca do vídeo.**
|
||||
- **14 scripts `check-*.mjs`** (cycles, route-validation, any-budget, docs-sync, docs-counts, env-doc-sync, deprecated-versions, doc-links, cli-i18n, openapi-coverage, openapi-security-tiers, pr-test-policy, node-runtime, test-report-summary) — vários já são *gates de consistência fonte-vs-derivado*, o mesmo padrão que precisamos para anti-alucinação.
|
||||
- **PR test policy:** `check-pr-test-policy.mjs` já força "mudou código de produção ⇒ mudou teste" (diff base...HEAD).
|
||||
- **Cobertura sumarizada + comentada no PR:** `test-report-summary.mjs` + `coverage/coverage-summary.json` + job `coverage-pr-comment` (comentário com marcador `<!-- omniroute-coverage-report -->`). **Isto é exatamente o "artefato legível pelo agente" do vídeo** — já construído.
|
||||
- **Disciplina TDD institucionalizada** (Hard Rule #18: todo fix precisa de teste falha→passa ou validação ao vivo no VPS).
|
||||
- **SonarQube** configurado (job no CI + `sonar-project.properties`).
|
||||
- **Skills agênticas de review** já existem: `/review-prs`, `/review-reviews` (bateria de 8 reviewers + ralph-loop), `/code-review`, `/generate-release` (a única com babysit real de CI, mas de workflows de *release*, não do `ci.yml` do PR).
|
||||
|
||||
### 2.2 O que está DESLIGADO ou inerte ⚠️ (achados da auditoria)
|
||||
|
||||
| Item | Estado | Evidência | Impacto |
|
||||
|------|--------|-----------|---------|
|
||||
| **Husky** | 100% comentado (pre-commit **e** pre-push) | `.husky/pre-commit`, `.husky/pre-push` (todas as linhas com `#`) | Zero enforcement local — lint-staged, docs-sync, any-budget, env-doc-sync, openapi checks e o `test:unit` de pre-push dependem 100% do CI. |
|
||||
| **Gate de cobertura no CI** | **40/40/40/40** (não 60) | `ci.yml:377` `--statements 40 --lines 40 --functions 40 --branches 40` | O comentário do PR renderiza contra 60, o script local gata 60, o RELEASE_CHECKLIST diz 75/70, e o baseline real é ~79–82%. **O único número que bloqueia merge é 40** → gate de cobertura quase banguela. |
|
||||
| **Sonar coverage** | Excluído | `sonar-project.properties:9` `sonar.coverage.exclusions=**/*` | Sonar ignora cobertura de todo arquivo. |
|
||||
| **Sonar CPD (duplicação)** | Excluído | `sonar-project.properties:10` `sonar.cpd.exclusions=**/*` | Sonar não detecta copy-paste — a assinatura nº1 de slop de IA. |
|
||||
| **SonarQube job** | Inerte | `ci.yml` roda scan só se `PR && SONAR_TOKEN != '' && SONAR_HOST_URL != ''`; sem `qualitygate.wait` | Em runs sem secret, escreve "skipped"; mesmo quando roda, nunca falha o build. |
|
||||
| **`npm audit`** | Plano (`moderate`), não escalonado | `package.json:112` `--audit-level=moderate` | O vídeo prega critical=bloqueia / high=avisa. O nosso é um nível único. |
|
||||
| **3 scripts órfãos** | Sem CI nem husky | `check:cli-i18n`, `check:openapi-coverage`, `check:openapi-security-tiers` | Existem, dão `exit 1`, mas não rodam em lugar nenhum (Hard Rules #15/#17 guardadas só por um deles). |
|
||||
| **`typecheck:noimplicit:core`** | `continue-on-error: true` | `ci.yml:45-46` | Warn-only "forward-looking". |
|
||||
|
||||
### 2.3 Hotspots de tamanho (sem gate hoje)
|
||||
|
||||
`64 arquivos > 1000 LOC`, `194 > 500 LOC` (src + open-sse, sem testes). Top:
|
||||
|
||||
| LOC | Arquivo | Risco para edição por IA |
|
||||
|-----|---------|--------------------------|
|
||||
| **12.760** | `src/app/(dashboard)/dashboard/providers/[id]/page.tsx` | God-component: **192 `useState`**, 21 `useEffect`, 87 `fetch()` inline, 34 tipos inline. Nenhuma IA segura em contexto; qualquer edição arrisca apagar estado não relacionado. |
|
||||
| 5.977 | `open-sse/handlers/chatCore.ts` | God-handler: 58 funções, invariantes demais, blast radius alto. |
|
||||
| 4.590 | `open-sse/config/providerRegistry.ts` | Array gigante providers+models+OAuth; mistura `resolvePublicCred()` e literais crus. |
|
||||
| 4.456 | `open-sse/services/combo.ts` | 14 estratégias num `if/else if` sem enum/exhaustiveness — estratégia desconhecida vira no-op silencioso. |
|
||||
| 4.349 | `src/app/(dashboard)/dashboard/combos/page.tsx` | 51 `useState`, mesmo padrão god-component. |
|
||||
| 4.205 | `src/lib/providers/validation.ts` | Mega-função com closures e `SPECIALTY_VALIDATORS` definidos *dentro* da função. |
|
||||
| 3.776 | `open-sse/handlers/imageGeneration.ts` | Branching multi-provider num handler. |
|
||||
| 3.076 | `src/shared/constants/providers.ts` | 229 ids em 27 consts agrupados via `Proxy` — sem lista plana. |
|
||||
| 2.869 | `open-sse/executors/chatgpt-web.ts` | Sessão web reversa; classe só começa na linha 2443. |
|
||||
| 2.278 | `src/app/api/providers/[id]/models/route.ts` | God-route: importa ~30 módulos provider-específicos e ramifica por provider num GET. |
|
||||
|
||||
### 2.4 Ímãs de alucinação (ranqueados, da auditoria)
|
||||
|
||||
1. **Split de provider em 3 arquivos / 2 workspaces (nº1).** Não há lista plana de providers — 229 ids escondidos atrás de 27 consts + merge via `Proxy` (`AI_PROVIDERS`). Uma IA não consegue enumerar "quais providers existem" barato → **inventa ids plausíveis** (variantes `*-web`/`*-cli` inexistentes) ou registra no grupo errado. As três contagens (`providers.ts` 229 ids ↔ `providerRegistry.ts` 155 blocos ↔ `validation.ts` N validadores) **já divergem**, então não há cross-check autoritativo. *(Esse é o tema recorrente das nossas memórias de alucinação — ex.: ids inventados, modelos inexistentes.)*
|
||||
2. **300 paths `fetch("/api/...")` hardcoded** no dashboard (659 call sites), sem client tipado. Refatore uma rota e os call sites apodrecem silenciosamente; uma IA editando a UI inventa rota (`/api/providers/[id]/refresh`) ou assume `res.error.message` numa rota que devolve `{error:"..."}`. Sem ligação de símbolo entre os 659 call sites e os 488 `route.ts`.
|
||||
3. **Dual chat stack (armadilha documentada).** Seleção/fallback de conta vive em `src/sse/` (não `open-sse/`). Uma IA pedida para "consertar fallback de conta" edita `open-sse/handlers/chatCore.ts` (errado) em vez de `src/sse/services/auth.ts`. Nada no código cruza os dois stacks. *(Já erramos um diagnóstico público por isso.)*
|
||||
4. **Estratégias de combo inventadas.** 14 nomes reais soterrados num `if/else` de strings (sem enum exportado) → IA inventa nomes plausíveis-mas-falsos (`"latency-optimized"`, `"failover"`) que passam no typecheck como string e viram no-op.
|
||||
5. **Métodos de executor inventados.** O padrão real é "sobrescreve `execute()` inteiro" (48/50 executors), sem hooks documentados → IA inventa `buildRequest`/`parseChunk`/`mapError` que não existem em `BaseExecutor`.
|
||||
6. **Helpers de erro inventados** + queda para `err.message` cru (viola Rule #12) quando o helper inventado "falha"; 5 web executors hoje **não importam helper nenhum**.
|
||||
7. **AGENTS.md de DB defasado:** documenta 21 migrations / 22 módulos quando o real é **94 / 75** — uma IA lendo isso acredita em conjuntos de tabelas/módulos que não existem mais.
|
||||
8. **Route-guard omitido:** rotas novas spawn-capazes (`/api/services/`, `/api/mcp/`) devem entrar em `LOCAL_ONLY_API_PREFIXES`; a convenção está só no CLAUDE.md, não num teste que a IA veja (parcialmente coberta por 1 script órfão).
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap analysis — modelo do vídeo vs OmniRoute
|
||||
|
||||
| Métrica/peça do vídeo | OmniRoute hoje | Gap |
|
||||
|-----------------------|----------------|-----|
|
||||
| `npm ci` determinístico | ✅ em todos os 14 jobs | — |
|
||||
| `npm audit` critical=bloqueia / high=avisa | ⚠️ `--audit-level=moderate` (nível único) | **Escalonar** em dois invokes |
|
||||
| `lint` | ✅ bloqueante | — |
|
||||
| `test` + cobertura | ✅ mas piso **40** no CI (drift) | **Reconciliar** p/ baseline real + catraca |
|
||||
| **Contagem de ESLint congelada** | ❌ (lint é 0-erros, mas warnings livres) | **Construir** (ratchet de violações) |
|
||||
| **Duplicação % (JSCPD)** | ❌ (Sonar CPD excluído, sem jscpd) | **Construir** (jscpd + catraca) |
|
||||
| **Limite de tamanho de arquivo** | ❌ (sem `max-lines`, sem script) | **Construir** (ESLint max-lines + catraca, freeze dos 64) |
|
||||
| **`baseline.json` congelado** | ❌ (nenhum baseline de métricas no repo) | **Construir** (o coração da catraca) |
|
||||
| **Script comparador (regra de ouro)** | 🟡 existe **para `any`** (`any-budget`) | **Generalizar** p/ todas as métricas |
|
||||
| **Sumário markdown + artefatos p/ o agente** | ✅ (coverage summary + PR comment + artifact) | **Reusar** wholesale |
|
||||
| **Babysit skill (monitora CI + resolve conversas)** | 🟡 `review-prs`/`review-reviews`/`generate-release` parciais; nenhuma resolve threads do PR nem loopa no `ci.yml` | **Construir** `/babysit` |
|
||||
| **Comentários perto do código p/ legibilidade de agente** | 🟡 `routeGuard.ts` é exemplar; resto irregular | **Padrão cultural** (Karpathy/guidelines) |
|
||||
|
||||
---
|
||||
|
||||
## 4. O que o mundo faz em 2026 (pesquisa, últimos ~3 meses)
|
||||
|
||||
> Todas as fontes abaixo vêm com URL + data nas seções de origem (ver §7). Onde a pesquisa **não conseguiu confirmar** algo de fonte primária, está marcado **[não-verificado]** — honestidade de engenharia.
|
||||
|
||||
### 4.1 Catraca / baseline-freeze (a "catraca" do vídeo)
|
||||
|
||||
- **betterer** — o tool canônico de ratchet (snapshot de métrica → `.betterer.results`; CI falha se piora, auto-atualiza se melhora). **[caveat]** Baixa velocidade: último commit no `master` em **ago/2025**, releases vazias no GitHub. Viável, mas **não** apostar como peça load-bearing de longo prazo.
|
||||
- **eslint-formatter-ratchet** — formatter que congela contagem de violações de ESLint. **Ativamente mantido** (commit 2026-03-17). Mais estreito (só ESLint) mas é a trajetória oposta ao betterer.
|
||||
- **SonarQube "Clean as You Code" (new-code conditions)** — o padrão baseline-freeze mais maduro: o quality gate aplica condições **só ao código novo** (branch de referência), grandfathering do legado. Atual.
|
||||
- **SonarQube "AI Code Assurance" (2026.1.0)** — gate específico para código gerado por IA (tag o projeto → workflow de assurance + "Sonar way for AI Code" mais estrito). **[não-verificado]** se *bloqueia* o PR (docs canônicas deram 404; descrito como "enforced quality gate" mas mecânica de bloqueio não confirmada em fonte única).
|
||||
- **Qlty CLI (qlty.sh)** — o produto 2026 mais aderente: CLI Rust **OSS e grátis** (v0.630.0, 2026-05-08) que agrega 70+ analisadores; tem **Baseline analysis** (= a catraca), **Quality Gates** com veredito go/no-go e coverage gates. **[caveat]** `qlty metrics` (a tabela LOC/complexidade) **não tem flag JSON** — só `qlty check --sarif`/`qlty smells --sarif`; o ratchet de tamanho/complexidade por arquivo ainda precisa do JSON do ESLint.
|
||||
- **Code Climate Quality → Qlty** — a marca clássica de ratchet virou empresa separada (Qlty, nov/2024). Write-ups antigos de "Code Climate" = Qlty hoje.
|
||||
|
||||
### 4.2 Ferramentas de métrica por tipo (todas com JSON p/ alimentar a catraca)
|
||||
|
||||
| Métrica | Tool 2026 | Comando JSON | Status |
|
||||
|---------|-----------|--------------|--------|
|
||||
| Duplicação | **jscpd v5** (reescrita Rust) | `jscpd --reporters json` (ou `sarif`) | Muito ativo (v5.0.4, 2026-06-08). **[caveat]** schema JSON v4→v5 não confirmado — verificar no install. |
|
||||
| Tamanho/fn-length/ciclomática | **ESLint core** (`max-lines`, `max-lines-per-function`, `complexity`) | `eslint --format json` | Built-in ESLint 9 |
|
||||
| Complexidade cognitiva | **eslint-plugin-sonarjs** (`sonarjs/cognitive-complexity`) | `eslint --format json` | Mantido (v4.0.3, 2026-04-16; agora no monorepo SonarJS — o repo standalone foi arquivado, mas o pacote está vivo). **[caveat]** README das rules deu 404; presença de S3776 em v4 é alta-confiança mas confirmar no install. |
|
||||
| Dead code / unused exports / unused deps | **knip** (vence ts-prune **arquivado** + depcheck **arquivado**) | `knip --reporter json` | Muito ativo (v6.16.1, 2026-06-06) |
|
||||
| Ciclos | **check-cycles.mjs** (já temos) + **dpdm** opcional | `dpdm --circular --output deps.json` | dpdm ativo (v4.2.0, 2026-05-09); madge estagnado |
|
||||
| Vulnerabilidades | **osv-scanner** (Google/OSV) | `osv-scanner --format json` | Muito ativo (push 2026-06-08) |
|
||||
| Política de lockfile (gate, não métrica) | **lockfile-lint** | `lockfile-lint --validate-https --validate-integrity` | Mantido (v5.0.0, 2026-01-25) |
|
||||
|
||||
> **Realidade do ratchet:** não existe tool único 2026 que emita *todas* as métricas como um JSON limpo. O padrão robusto é **N tools que emitem JSON + um reducer Node** que monta `metrics-summary.json` + o comparador que falha só em regressão (exatamente o que o `any-budget` já faz para uma métrica).
|
||||
|
||||
### 4.3 Anti-alucinação (2026)
|
||||
|
||||
- **LSP-in-the-loop / `agent-lsp` (MCP)** — servidor MCP que dá ao agente fatos verificáveis do language server (definições, referências, tipos, diagnostics, `blast_radius`) e `preview_edit` antes de escrever. Funciona com Claude Code. **Fit alto:** vira "símbolo inventado" de catch-de-review para *impossibilidade-no-edit*. (v0.13.0, 2026-06-04 — pequeno mas ativo.)
|
||||
- **Slopsquatting / pacotes alucinados** — **CSA Research Note (2026-04-19):** **19,7%** de 2,23M amostras de código IA continham nomes de pacote alucinados; 205k nomes fabricados únicos; **43%** reaparecem em re-runs (registráveis por atacantes). Defesas: **allowlist** de deps para agentes, **registry existence check** antes de instalar, **age-cooldown** (24–72h), lockfile-exact, scripts de install desabilitados. **Fit alto:** novo `check-deps.mjs`.
|
||||
- **Semcheck** (v1.2.1, fev/2026) — CLI que usa LLM para verificar que a implementação bate com o spec/doc, via `semcheck.yaml` ligando doc↔código; roda em pre-commit e Actions com `fail-on-issues`. Feito para pegar **"docs que descrevem features não implementadas"**. **Fit muito alto** para nossos incidentes recorrentes de docs alucinadas — porém é fuzzy (LLM); pareie com checks determinísticos.
|
||||
- **OpenAPI drift determinístico** — check que toda `path` do `openapi.yaml` resolve para um `route.ts` real (e vice-versa). Rápido, sem LLM, pega "endpoint inventado". **Fit alto** para `docs/reference/openapi.yaml`.
|
||||
- **Skill `verify` / `verification-before-completion`** (já no nosso ambiente) — "evidence before assertions": o veredito PASS/FAIL repousa **só** no que o app rodando demonstrou; rejeita "rodei os testes" como prova. **Fit altíssimo:** é a formalização da nossa Hard Rule #18 — exigir o *output literal* do comando colado no PR ("tool receipt").
|
||||
- **Adversarial review (críticos de sessão fresca)** — agentes Skeptic/Architect/Minimalist leem o diff *contra o spec* ("o autor está comprometido — vai racionalizar"); símbolos/APIs inventados viram violação de spec. Mapeia no nosso `/review-reviews`.
|
||||
- **SlopCodeBench (arXiv 2603.24755, ~mai/2026)** — sem mitigação, erosão estrutural aumentou em **77%** das trajetórias; o código de agente acumula verbosidade ~7× e erosão ~5× mais rápido que repos humanos. **Mitigações só-de-prompt ("anti-slop", "plan-first") melhoram o início mas NÃO param a degradação iterativa.** → **justificativa empírica** de que precisamos de gates determinísticos, não instrução.
|
||||
- **GPT-5.5 System Card (2026-04-23)** — figuras oficiais são modestas (23% mais provável de acerto factual; 3% menos erros num set propenso). O headline de **"queda de 60% em alucinação / 88,7% SWE-bench" é imprensa secundária [não-verificado]**, não a seção de factualidade do system card. Upgrade de modelo ajuda na margem, não substitui gate.
|
||||
|
||||
### 4.4 Babysit loops (2026)
|
||||
|
||||
- **Claude Code "auto-fix in the cloud"** (Anthropic, lançado 2026-03-27): "observa seus PRs na nuvem, resolvendo falhas de CI e comentários de review automaticamente; empurra fixes quando claro, pergunta quando ambíguo." Não auto-mergeia.
|
||||
- **Devin Autofix** (2026-02-10): auto-conserta comentários de review + lint/CI; endereça comentários de bots, mas deixa julgamento humano nas conversas humanas.
|
||||
- **CodeRabbit Autofix** (early access abr/2026): coleta o bloco **"Prompt for AI Agents"** de cada comentário, aplica fix, roda build-verification; **nada mergeia automaticamente**.
|
||||
- **Greptile `greploop` + skill `check-pr`** (MIT) — "dispara review → conserta comentários → re-review até 5/5 de confiança e zero comentários". **Template quase-exato** para a nossa `/babysit`.
|
||||
- **Claude `claude ultrareview <PR#> --json`** (subcomando não-interativo, research preview): bloqueia até terminar, `exit 0/1`, payload de bugs verificados parseável. **Não auto-inicia** e custa $5–20/run → usar atrás de label, não em todo push. (O `/code-review ultra` local com `--fix` é o loop interno de custo-zero.)
|
||||
- **Resolver threads de review:** não há comando `gh` nativo (cli/cli#12419). Padrão de 2 passos GraphQL: `reviewThreads(first:50){nodes{id isResolved...}}` → `mutation { resolveReviewThread(input:{threadId}) }`, respondendo antes com o SHA do commit via REST.
|
||||
- **Guarda-corpos (críticos):**
|
||||
- **Snyk Agent Fix field test (2026): ~5,3% de regressão** ("1 em 19 fixes auto-mergeados introduz problema novo") → **forte argumento contra auto-merge**.
|
||||
- **Token burn:** loops ingênuos realimentam a conversa crescente → prompt incha → alucina do próprio histórico. Uber capou gasto em **$1.500/mês/dev/tool** (abr/2026). Mitigação: **max-iterations**, time limit, idle-exit.
|
||||
- **Test-masking:** o babysit **NÃO** pode enfraquecer/remover asserts para ficar verde (= nossa Rule #18 + memória "trust but verify"). Revisão humana fica nos limites arquiteturais (interface/schema/cross-service).
|
||||
- **Audit trail:** deixar rastro humano-legível (qual fix endereçou o quê, qual gate satisfez, quais conversas resolveu) — nunca um verde silencioso. Reforça o guard de prompt-injection sobre os *corpos de comentário* que o agente ingere (21% dos reviews do ICLR 2026 eram IA; injeção embutida em código é vetor real).
|
||||
|
||||
---
|
||||
|
||||
## 5. Recomendações (ranqueadas) → ver o PLANO
|
||||
|
||||
> Build-vs-buy: **construir in-repo** os gates determinísticos (zero SaaS, dados não saem da box, reusa o harness `check-*.mjs`). **Avaliar Qlty CLI** depois, se quisermos consolidar N scripts num tool. **Não** depender de CodeRabbit/Greptile/Diamond como *o* gate de "não-piorar-métrica" — são opiniões de LLM, não contadores determinísticos.
|
||||
|
||||
**Fase 0 — Reativar & reconciliar (quick wins, sem tooling novo):**
|
||||
1. Reativar pre-commit barato do Husky (lint-staged + docs-sync + any-budget).
|
||||
2. Reconciliar o gate de cobertura: subir o CI de 40 → baseline real (com headroom) e alinhar os 4 lugares que divergem.
|
||||
3. Escalonar `npm audit` (critical=bloqueia / high=avisa).
|
||||
4. Plugar os 3 scripts órfãos (`cli-i18n`, `openapi-coverage`, `openapi-security-tiers`) no CI.
|
||||
|
||||
**Fase 1 — Motor de catraca (o coração):**
|
||||
5. `quality-baseline.json` commitado + `collect-metrics.mjs` (coletor) + `check-quality-ratchet.mjs` (comparador, clone do `any-budget`) + job de CI + artefato + comentário no PR (clone do `coverage-pr-comment`).
|
||||
|
||||
**Fase 2 — Gates determinísticos anti-alucinação:**
|
||||
6. `check-provider-consistency.mjs` (o ímã nº1), `check-fetch-targets.mjs`, `check-openapi-routes.mjs`, allow-list de estratégias/translators/executors, lint Rule #11/#12, `check-deps.mjs` (slopsquatting).
|
||||
|
||||
**Fase 3 — Catraca de duplicação + tamanho (mata-slop):**
|
||||
7. jscpd + ESLint `max-lines`/`max-lines-per-function`/`complexity` + `sonarjs/cognitive-complexity`, congelando os 64 arquivos grandes (catraca só-pode-encolher).
|
||||
|
||||
**Fase 4 — Catraca de cobertura + anti test-masking:**
|
||||
8. `check-coverage-ratchet.mjs` (cobertura não cai vs baseline) + pisos por módulo crítico + `check-test-masking.mjs` (delta de contagem de asserts em testes alterados).
|
||||
|
||||
**Fase 5 — Skill `/babysit` + evidência + LSP:**
|
||||
9. Skill `/babysit` (gh pr checks + reviewThreads + worktree de fix + resolveReviewThread + loop-até-verde, com guarda-corpos), "evidence-before-assertions" obrigatório no corpo do PR, e (opcional) `agent-lsp` MCP.
|
||||
|
||||
---
|
||||
|
||||
## 6. Riscos & ressalvas (honestidade de engenharia)
|
||||
|
||||
- **Flag-day risk:** ligar qualquer gate num projeto que nunca o teve deixa tudo vermelho. **Toda** catraca aqui é *só-regressão* (baseline congelado), nunca um piso absoluto que exige limpeza imediata — exatamente o ponto do vídeo.
|
||||
- **Custo de IA:** o babysit pode queimar tokens. Guarda-corpos (max-iterations, sem auto-merge, sem editar `.github/workflows/`) são não-negociáveis.
|
||||
- **Ressalvas de pesquisa não-verificadas:** betterer baixa-velocidade; Sonar "AI Code Assurance" bloqueio-de-PR não confirmado; "GPT-5.5 −60% alucinação" é imprensa, não system card; schema JSON do jscpd v5 e S3776 do sonarjs v4 a confirmar no install; `qlty metrics` sem JSON. Nenhuma decisão do plano depende criticamente de um item não-verificado.
|
||||
- **Trust-but-verify:** estes números internos (CI=40, husky off, sonar exclusions, 12.760 LOC, any-budget como catraca) foram **conferidos manualmente** contra os arquivos, não só relatados pelos subagentes.
|
||||
|
||||
---
|
||||
|
||||
## 7. Fontes (consolidadas)
|
||||
|
||||
**Catraca / ratchet:** betterer `github.com/phenomnomnominal/betterer` (último master ago/2025); eslint-formatter-ratchet `github.com/Jmsa/eslint-formatter-ratchet` (commit 2026-03-17); SonarQube Clean as You Code `docs.sonarsource.com/.../clean-as-you-code/about-new-code/`; Sonar AI Code Assurance `sonarsource.com/solutions/ai/ai-code-assurance/` + community thread 2026.1.0; Qlty `github.com/qltysh/qlty` (v0.630.0, 2026-05-08), `docs.qlty.sh`; Code Climate→Qlty `codeclimate.com/legacy/...` (2024-11-11).
|
||||
|
||||
**Métricas:** jscpd `github.com/kucherenko/jscpd` (v5.0.4, 2026-06-08); ESLint v10 `eslint.org/blog/2026/02/eslint-v10.0.0-released/` (2026-02-06); eslint-plugin-sonarjs `npm` (v4.0.3, 2026-04-16) + `github.com/SonarSource/SonarJS`; knip `knip.dev` (v6.16.1, 2026-06-06); dpdm `github.com/acrazing/dpdm` (v4.2.0, 2026-05-09); osv-scanner `google.github.io/osv-scanner` (push 2026-06-08); lockfile-lint `github.com/lirantal/lockfile-lint` (v5.0.0, 2026-01-25); GitClear `gitclear.com/ai_assistant_code_quality_2025_research`.
|
||||
|
||||
**Anti-alucinação:** agent-lsp `github.com/blackwell-systems/agent-lsp` (v0.13.0, 2026-06-04); CSA Slopsquatting `labs.cloudsecurityalliance.org/research/...slopsquatting...20260419...` (2026-04-19); Nesbitt package defenses `nesbitt.io/2026/04/09/...` (2026-04-09); Semcheck `github.com/rejot-dev/semcheck` (v1.2.1, fev/2026); verify skill `github.com/Piebald-AI/claude-code-system-prompts/.../skill-verify-skill.md`; Claude best practices `code.claude.com/docs/en/best-practices`; SlopCodeBench `arxiv.org/pdf/2603.24755`; GPT-5.5 system card `deploymentsafety.openai.com/gpt-5-5` (2026-04-23); adversarial review `asdlc.io/patterns/adversarial-code-review/`; OpenAPI drift `speakeasy.com/blog/openapi-spec-drift-detection`.
|
||||
|
||||
**Babysit:** Claude auto-fix cloud `producthunt.com/products/claude-code-auto-fix-in-the-cloud` (2026-03-27); Devin Autofix `cognition.ai/blog/closing-the-agent-loop-...` (2026-02-10); CodeRabbit Autofix `coderabbit.ai/blog/fix-all-issues-with-ai-agents` (2026-02-19); Greptile skills `github.com/greptileai/skills`; Claude GitHub Actions/Code Review/ultrareview `code.claude.com/docs/en/{github-actions,code-review,ultrareview}`; Nx self-healing `nx.dev/blog/autonomous-ai-workflows-with-nx` (2026-02-03); Snyk Agent Fix 5,3% `safeguard.sh/resources/blog/snyk-agent-fix-autofix-field-test-2026`; resolveReviewThread `nakamasato.medium.com/...` + `github.com/cli/cli/issues/12419`; ICLR 2026 AI review `blog.pebblous.ai/report/iclr-2026-ai-peer-review-crisis`.
|
||||
|
||||
---
|
||||
|
||||
*Relatório gerado a partir de auditoria paralela do código + transcrição do vídeo + pesquisa web 2026. Próximo passo: aprovar o [`PLANO-QUALITY-GATES.md`](./PLANO-QUALITY-GATES.md) e escolher por onde começar (recomendação: Fase 0 → Fase 1).*
|
||||
44
SECURITY.md
44
SECURITY.md
@@ -38,17 +38,15 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
|
||||
|
||||
### 🔐 Authentication & Authorization
|
||||
|
||||
| Feature | Implementation |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
|
||||
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
| Feature | Implementation |
|
||||
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **API Key Auth** | HMAC-signed keys with CRC validation |
|
||||
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **Token Refresh** | Automatic OAuth token refresh before expiry |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
|
||||
| **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
|
||||
|
||||
### 🛡️ Encryption at Rest
|
||||
|
||||
@@ -208,29 +206,6 @@ These rules are enforced by tooling and reviewers:
|
||||
10. **`exec()` / `spawn()` runtime values via the `env` option** — never string-interpolate external paths or untrusted values into shell-passed scripts. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
|
||||
11. **Prefer secure-by-default libraries** — see [tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults) (Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink). Reach for them before rolling your own.
|
||||
|
||||
## Supply-chain scanner findings (Socket.dev / Snyk / similar)
|
||||
|
||||
The published `omniroute` npm artifact bundles the Next.js `output: "standalone"`
|
||||
build, which means every route handler — including documented privileged
|
||||
features (MITM, Zed import, Cloud Sync, embedded service supervisor) — ends
|
||||
up in `.next/server/*.js` minified chunks. Heuristic supply-chain scanners
|
||||
frequently pattern-match those chunks against malware signatures.
|
||||
|
||||
For each finding category we maintain a per-finding maintainer attestation:
|
||||
|
||||
- **[`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)** —
|
||||
per-finding map: source file ↔ flagged chunk ↔ behaviour ↔ mitigation
|
||||
applied in v3.8.6.
|
||||
- In-source `SECURITY-AUDITOR-NOTE:` blocks at each flagged function point
|
||||
back to the same document.
|
||||
|
||||
For users whose pipeline cannot relax the alert: build with
|
||||
`OMNIROUTE_BUILD_PROFILE=minimal npm run build`. That replaces the four
|
||||
sensitive modules with stubs that return HTTP 503 `feature-disabled` at
|
||||
runtime, so the privileged code paths are physically absent from the bundle.
|
||||
See [`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md)
|
||||
for the publishing recipe.
|
||||
|
||||
## References
|
||||
|
||||
- [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) — authorization pipeline
|
||||
@@ -238,7 +213,6 @@ for the publishing recipe.
|
||||
- [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) — audit log and retention
|
||||
- [`docs/security/PUBLIC_CREDS.md`](docs/security/PUBLIC_CREDS.md) — **mandatory** pattern for public upstream credentials
|
||||
- [`docs/security/ERROR_SANITIZATION.md`](docs/security/ERROR_SANITIZATION.md) — **mandatory** pattern for error responses
|
||||
- [`docs/security/SOCKET_DEV_FINDINGS.md`](docs/security/SOCKET_DEV_FINDINGS.md) — maintainer attestation for supply-chain scanner findings
|
||||
- [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) — circuit breaker + cooldown + lockout
|
||||
- [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) — TLS fingerprinting (legal/ethical notice)
|
||||
- [`CLAUDE.md`](CLAUDE.md) — hard rules for AI agents
|
||||
|
||||
178
Tuto_Qdrant.md
Normal file
178
Tuto_Qdrant.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Tutorial Qdrant no OmniRoute (Guia para vídeo)
|
||||
|
||||
> ⚠️ **Status (v3.8.0):** Integração Qdrant está **dormente** no pipeline. As funções de upsert/search/delete existem em `src/lib/memory/qdrant.ts` e a UI de configuração está pronta (`MemorySkillsTab.tsx` + endpoint `/api/settings/qdrant/embedding-models`), mas:
|
||||
>
|
||||
> - `upsertSemanticMemoryPoint`, `searchSemanticMemory` e `deleteSemanticMemoryPoint` **não são chamadas** pelo pipeline de chat — busca semântica corrente usa apenas o store local em SQLite (ver `docs/frameworks/MEMORY.md`).
|
||||
> - As rotas `/api/settings/qdrant/health`, `/api/settings/qdrant/search` e `/api/settings/qdrant/cleanup` mencionadas neste tutorial **ainda não foram implementadas**.
|
||||
> - Os botões "Testar conexão" e "Teste de busca" no painel exigem que essas rotas existam; até lá, são placeholders.
|
||||
>
|
||||
> Este documento descreve a UX/configuração planejada. Para o sistema de memória ativo hoje, consulte [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md). Acompanhe o status da ativação em issues marcadas com `area:qdrant`.
|
||||
|
||||
## 1) O que é o Qdrant no OmniRoute
|
||||
|
||||
O Qdrant é o banco vetorial usado para memória semântica.
|
||||
|
||||
No OmniRoute, ele ajuda a:
|
||||
|
||||
- Encontrar contexto por significado (não só palavra exata).
|
||||
- Reaproveitar memórias antigas com mais precisão.
|
||||
- Melhorar respostas com base em histórico relevante.
|
||||
- Escalar melhor quando a base de memória cresce.
|
||||
|
||||
---
|
||||
|
||||
## 2) Quando o OmniRoute envia dados para o Qdrant
|
||||
|
||||
Com Qdrant habilitado e modelo de embedding configurado, o sistema envia vetores quando:
|
||||
|
||||
- Memórias são salvas (upsert de memória).
|
||||
- Fluxos de chat recuperam contexto semântico/híbrido.
|
||||
- Testes de busca no painel geram embedding e consultam a coleção.
|
||||
|
||||
Resumo prático:
|
||||
|
||||
- Sem Qdrant: busca mais limitada (texto/chave).
|
||||
- Com Qdrant: busca por similaridade semântica (mais inteligente).
|
||||
|
||||
---
|
||||
|
||||
## 3) Pré-requisitos
|
||||
|
||||
Você precisa de:
|
||||
|
||||
- Instância Qdrant acessível (porta 6333).
|
||||
- Coleção criada (ex.: `omniroute_memory`).
|
||||
- Modelo de embedding válido (ex.: OpenRouter).
|
||||
- Credencial do provider do embedding configurada no OmniRoute.
|
||||
|
||||
Exemplo de modelo OpenRouter:
|
||||
|
||||
- `openrouter/nvidia/llama-nemotron-embed-v1-1b-v2:free`
|
||||
|
||||
Importante:
|
||||
|
||||
- O texto do modelo deve estar em formato `provider/model`.
|
||||
- Se usar modelo com dimensão diferente da coleção, a busca falha.
|
||||
|
||||
---
|
||||
|
||||
## 4) Como configurar no painel do OmniRoute
|
||||
|
||||
No menu:
|
||||
|
||||
- `Admin > Settings > Qdrant (Memória vetorial)`
|
||||
|
||||
Preencha:
|
||||
|
||||
- `Ativar Qdrant`: ligado.
|
||||
- `Host`: IP ou URL do servidor Qdrant (sem porta no campo Host).
|
||||
- `Porta`: `6333`.
|
||||
- `Collection`: `omniroute_memory` (ou nome que você criou).
|
||||
- `Modelo de embedding`: selecione da lista ou digite manualmente.
|
||||
- `API Key`: opcional (preencha se seu Qdrant exigir).
|
||||
|
||||
Depois:
|
||||
|
||||
1. Clique em `Salvar`.
|
||||
2. Clique em `Testar conexão`.
|
||||
3. No `Teste de busca`, digite um texto e clique em `Buscar`.
|
||||
|
||||
---
|
||||
|
||||
## 5) Como criar a coleção no Dashboard do Qdrant (sem comando)
|
||||
|
||||
No Qdrant Dashboard:
|
||||
|
||||
1. Clique em `Create collection`.
|
||||
2. Escolha `Global search`.
|
||||
3. Em tipo de busca, use `Custom`.
|
||||
4. Configure vetor:
|
||||
- Vector name: `omniao` (padrão esperado pelo OmniRoute atualmente).
|
||||
- Size: dimensão do seu modelo de embedding (ex.: 2048 em alguns modelos NVIDIA).
|
||||
- Distance: `Cosine`.
|
||||
5. Salve a coleção com nome `omniroute_memory`.
|
||||
|
||||
Se já tinha coleção com dimensão errada:
|
||||
|
||||
- Recrie a coleção com dimensão correta.
|
||||
|
||||
---
|
||||
|
||||
## 6) Como validar se está funcionando
|
||||
|
||||
Checklist rápido:
|
||||
|
||||
1. `Testar conexão` no OmniRoute retorna OK.
|
||||
2. Busca no painel retorna resultados (não “Sem resultados”).
|
||||
3. No Qdrant Dashboard, aparecem pontos na coleção (payload + vector).
|
||||
4. Resultados de chat passam a recuperar contexto mais relevante.
|
||||
|
||||
Sinal clássico de problema:
|
||||
|
||||
- Dados entram no Qdrant, mas busca do painel não retorna nada.
|
||||
|
||||
Causas comuns:
|
||||
|
||||
- Dimensão do vetor incompatível.
|
||||
- Nome do vetor diferente do esperado (`omniao`).
|
||||
- Modelo inválido/incompleto no campo de embedding.
|
||||
- Provider sem credencial ativa.
|
||||
|
||||
---
|
||||
|
||||
## 7) O que melhorou com esta atualização
|
||||
|
||||
Nesta melhoria do OmniRoute:
|
||||
|
||||
- Suporte a embeddings de qualquer provider compatível (não só OpenAI fixo).
|
||||
- Endpoint para carregar modelos de embedding na tela de configurações.
|
||||
- Campo manual para modelo custom quando não aparecer na lista.
|
||||
- Ajuda visual (`?`) com passo rápido de configuração Qdrant + OpenRouter.
|
||||
|
||||
---
|
||||
|
||||
## 8) Roteiro curto para seu vídeo
|
||||
|
||||
Sugestão de demo (3-5 minutos):
|
||||
|
||||
1. Mostrar problema sem Qdrant (busca simples).
|
||||
2. Abrir Settings e habilitar Qdrant.
|
||||
3. Configurar host/porta/collection/modelo.
|
||||
4. Salvar + testar conexão.
|
||||
5. Fazer `Teste de busca` no painel.
|
||||
6. Abrir Qdrant Dashboard e mostrar ponto salvo + vetor.
|
||||
7. Rodar um chat e mostrar melhoria de recuperação semântica.
|
||||
|
||||
Mensagem final para a galera:
|
||||
|
||||
- "Qdrant no OmniRoute transforma memória de palavra-chave em memória por significado."
|
||||
|
||||
---
|
||||
|
||||
## 9) Referências de código (para equipe técnica)
|
||||
|
||||
**Implementado:**
|
||||
|
||||
- UI de configuração Qdrant: `src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx`
|
||||
- Endpoint de modelos de embedding: `src/app/api/settings/qdrant/embedding-models/route.ts`
|
||||
- Funções backend (definidas mas dormentes): `src/lib/memory/qdrant.ts` exporta `upsertSemanticMemoryPoint`, `searchSemanticMemory`, `deleteSemanticMemoryPoint`
|
||||
|
||||
**Pendente para ativar a integração:**
|
||||
|
||||
- Rotas API: `/api/settings/qdrant/health`, `/api/settings/qdrant/search`, `/api/settings/qdrant/cleanup`
|
||||
- Wire-up no fluxo de chat: `src/lib/memory/retrieval.ts` e `open-sse/handlers/chatCore.ts` precisam chamar `searchSemanticMemory` quando Qdrant estiver habilitado nas settings
|
||||
- Wire-up no save de memória: `src/lib/memory/extraction.ts` (ou camada equivalente) precisa chamar `upsertSemanticMemoryPoint` após persistir cada memória
|
||||
|
||||
Para o sistema de memória ativo hoje (SQLite-only), ver [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md).
|
||||
|
||||
---
|
||||
|
||||
## 10) Observação importante de segurança
|
||||
|
||||
Nunca exponha em vídeo:
|
||||
|
||||
- API key completa do OpenRouter.
|
||||
- Tokens reais de produção.
|
||||
- Endpoints internos sem proteção.
|
||||
|
||||
Use chaves mascaradas e ambiente de demonstração.
|
||||
1
_ideia/_fetch/1584.json
Normal file
1
_ideia/_fetch/1584.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjM3Nzc3MjYx","is_bot":false,"login":"uwuclxdy","name":"cloudy"},"body":"### Problem / Use Case\n\nI'm trying to connect [CoStrict](https://zgsm.sangfor.com) to omniroute but it uses oauth.\n\n### Proposed Solution\n\nAdd support for CoStrict provider\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\n- being able to authenticate with costrict account\n- token refresh, multiple accounts (all features as the other oauth providers)\n\n### Area\n\nProvider Support\n\n### Related Provider(s)\n\n_No response_\n\n### Additional Context\n\nread https://claude.ai/share/5d01e7b2-5771-4a96-9067-9c7fe1b4ba7a\n\n### Expected Test Plan\n\n_No response_","comments":[{"id":"IC_kwDORPf6ys8AAAABAXyEqw","author":{"login":"diegosouzapw"},"authorAssociation":"OWNER","body":"Thank you for the suggestion, @uwuclxdy. CoStrict (Sangfor's coding assistant) is an interesting OAuth-based provider.\n\nWe reviewed the shared analysis and the provider appears to use a standard OAuth 2.0 flow. Adding CoStrict would follow our existing OAuth provider pattern:\n1. OAuth constants in `src/lib/oauth/constants/oauth.ts`\n2. Executor in `open-sse/executors/` (likely extending the Claude Code-compatible executor given the Anthropic-style API)\n3. Token refresh flow in `open-sse/services/tokenRefresh.ts`\n\nWe'll track this for a future provider onboarding wave. If you have access to the actual OAuth client endpoints and API documentation beyond the shared analysis, that would help accelerate the integration.\n\nKeeping open for tracking.","createdAt":"2026-04-25T15:03:30Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1584#issuecomment-4319904939","viewerDidAuthor":true}],"createdAt":"2026-04-25T11:46:48Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1584,"title":"[Feature] CoStrict provider","url":"https://github.com/diegosouzapw/OmniRoute/issues/1584"}
|
||||
1
_ideia/_fetch/1594.json
Normal file
1
_ideia/_fetch/1594.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjc2ODU0NjI=","is_bot":false,"login":"newbe36524","name":"Newbe36524"},"body":"### Problem / Use Case\n\nMy upstream provider is an account pool. It occasionally returns temporary error messages due to exceptions, such as 403, 401 and other similar status codes.I want to retain and return these original errors without automatically downgrading the provider.The provider is still fully valid, and the issue can be resolved simply by retrying at the downstream level.\n\n### Proposed Solution\n\nI want to exclude specific providers or their keys from the break mechanism, so that they remain permanently enabled and will never be downgraded under any circumstances.\nCurrently, I rely on an external standalone process to check every second whether these providers have been downgraded, and re-enable them automatically if so. This workaround is cumbersome and inconvenient.\n\n### Alternatives Considered\n\n_No response_\n\n### Acceptance Criteria\n\nProvider can be set as permanent alive\n\n### Area\n\nProxy / Routing\n\n### Related Provider(s)\n\nCustom provider\n\n### Additional Context\n\n_No response_\n\n### Expected Test Plan\n\n_No response_","comments":[{"id":"IC_kwDORPf6ys8AAAABAXyEwg","author":{"login":"diegosouzapw"},"authorAssociation":"OWNER","body":"Thank you for the feature request, @newbe36524. This is a valid use case — account pool providers where transient 403/401 errors are expected and should be retried by the downstream client rather than triggering the circuit breaker.\n\nOmniRoute v3.7.0 already made progress in this direction:\n- **Removed 429 from `PROVIDER_FAILURE_ERROR_CODES`** — rate limits no longer trigger the provider-wide circuit breaker\n- **Configurable failure thresholds** via `PROVIDER_PROFILES` — different provider types can have different tolerance levels\n\nWhat you're describing would be a natural extension: a per-provider or per-connection **`circuitBreakerEnabled: false`** flag that completely bypasses the circuit breaker for specific connections, letting all errors pass through as-is for the downstream client to handle.\n\nImplementation would touch:\n- `open-sse/services/accountFallback.ts` — skip `markProviderFailure()` when flag is set\n- Provider `providerSpecificData` schema — add `circuitBreakerBypass: boolean`\n- Dashboard UI — toggle in the provider connection settings\n\nKeeping open for tracking. This is a clean, scoped feature that could land in a near-term release.","createdAt":"2026-04-25T15:03:31Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[{"content":"THUMBS_UP","users":{"totalCount":1}}],"url":"https://github.com/diegosouzapw/OmniRoute/issues/1594#issuecomment-4319904962","viewerDidAuthor":true}],"createdAt":"2026-04-25T13:58:11Z","labels":[{"id":"LA_kwDORPf6ys8AAAACYAlrLw","name":"enhancement","description":"New feature or request","color":"a2eeef"}],"number":1594,"title":"[Feature] permanent provider or the internal key inside the provider","url":"https://github.com/diegosouzapw/OmniRoute/issues/1594"}
|
||||
1
_ideia/_fetch/1716.json
Normal file
1
_ideia/_fetch/1716.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Enhancement: Shorter timeouts for zombie stream detection\n\n### Current behavior\n\nAll timeout-sensitive phases share `FETCH_TIMEOUT_MS` / `STREAM_IDLE_TIMEOUT_MS` (default 10 minutes). Additionally, there is **no request-level deadline** — if a request gets stuck in a pre-fetch phase (rate limiter queue, account semaphore, translation, etc.), it hangs indefinitely with no timeout to recover.\n\nObserved failure modes:\n- Upstream never returns HTTP headers → 10 min hang\n- Upstream returns HTTP 200 but never sends SSE data → 10 min hang before `ensureStreamReadiness` catches it\n- Upstream sends initial content then stalls mid-stream → 10 min hang before idle timeout\n- Request stuck in rate limiter queue or account fallback → **indefinite hang** (no timeout covers this phase)\n\n### Proposed behavior\n\nSplit timeouts into distinct phases, plus add a request-level hard deadline for pre-streaming phases:\n\n| Phase | Proposed default | Current | Env override |\n|-------|-----------------|---------|-------------|\n| **Request deadline** (pre-streaming phases) | 600s | none | `FETCH_TIMEOUT_MS` |\n| Fetch headers (time to HTTP response) | 60s | 600s | `FETCH_HEADERS_TIMEOUT_MS` |\n| First content (time to first useful SSE event) | 60s | 600s | `STREAM_FIRST_CONTENT_TIMEOUT_MS` |\n| Stream idle (mid-stream pauses) | 120s | 600s | `STREAM_IDLE_TIMEOUT_MS` |\n\n**Request deadline** covers the entire pre-streaming lifecycle (rate limiter wait, account fallback, translation, fetch setup, `ensureStreamReadiness`). Once the stream is confirmed active (useful content received), the deadline is cancelled — active streams are governed only by the idle timeout, which resets on every chunk. This means reasoning models that think for 10+ minutes are NOT cut, as long as they send tokens.\n\n### Implementation\n\n1. Add `STREAM_FIRST_CONTENT_TIMEOUT_MS` (default 60s) to `runtimeTimeouts.ts`\n2. Change `FETCH_HEADERS_TIMEOUT_MS` default from `fetchTimeoutMs` to 60s\n3. Lower `DEFAULT_STREAM_IDLE_TIMEOUT_MS` from 600s to 120s\n4. Use `STREAM_FIRST_CONTENT_TIMEOUT_MS` in `ensureStreamReadiness()` call (currently uses `STREAM_IDLE_TIMEOUT_MS`)\n5. Use `FETCH_HEADERS_TIMEOUT_MS` in `BaseExecutor.execute()` fetch-start timeout (currently uses `getTimeoutMs()`)\n6. Add `requestTimeoutMs` option to `createStreamController()` — starts a hard deadline timer when the request begins\n7. Cancel the deadline timer once `ensureStreamReadiness` passes (stream confirmed active)\n8. The deadline uses `FETCH_TIMEOUT_MS` as the default, configurable via env var\n\n### Impact\n\n- Zombie streams detected in ~60s instead of ~10min\n- Mid-stream stalls detected in ~2min instead of ~10min\n- **Pre-fetch hangs (rate limiter, account fallback) now covered** — 600s hard deadline prevents indefinite hangs\n- Combo fallback triggers much faster\n- No change to actively streaming behavior — deadline is cancelled once stream starts, and idle timer resets on every chunk\n- All timeouts are env-overridable for operators who need different values\n\n### Context\n\nObserved with mimo-v2.5-pro via opencode-go in three separate incidents:\n1. Provider returned HTTP 200 but never sent SSE data → 15+ min hang\n2. Provider sent initial SSE content then stalled completely → 13+ min hang\n3. Request stuck after account fallback (429 on first account, second account's request never reached fetch) → 24+ min hang with zero log output after fallback","comments":[],"createdAt":"2026-04-28T09:51:04Z","labels":[],"number":1716,"title":"[Feature] Separate fetch-start and first-content timeouts from stream idle timeout","url":"https://github.com/diegosouzapw/OmniRoute/issues/1716"}
|
||||
1
_ideia/_fetch/1718.json
Normal file
1
_ideia/_fetch/1718.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Enhancement: Propagate upstream error details to the client\n\n### Problem\n\nWhen an upstream provider returns an error, the client receives a generic message like:\n\n```\n[400]: Error from provider: Provider returned error\n```\n\nThe actual upstream error body is captured internally but never included in the response. This makes it difficult to debug issues — the real error (e.g., `context_length_exceeded`, `invalid_tool_call`, etc.) is hidden behind the generic wrapper.\n\n### Proposed behavior\n\nAdd an optional `upstream_details` field to error response bodies alongside the existing `error.message`/`type`/`code` structure. The existing error structure stays unchanged (OpenAI-compatible).\n\nExample response:\n\n```json\n{\n \"error\": {\n \"message\": \"[400]: Error from provider: Provider returned error\",\n \"type\": \"invalid_request_error\",\n \"code\": \"bad_request\"\n },\n \"upstream_details\": {\n \"error\": { \"message\": \"context_length_exceeded\", \"type\": \"invalid_request_error\" }\n }\n}\n```\n\n### Where this helps\n\n- Clients using providers that wrap errors in generic messages (e.g., opencode-go)\n- Debugging 400s from upstream providers where the real error is in the response body\n- Understanding why a specific request failed without checking server-side logs\n\n### Implementation notes\n\nThe upstream error body is already parsed and available in the error handling path. The change involves:\n1. Adding an optional parameter to error builder functions (`buildErrorBody`, `errorResponse`, `writeStreamError`, `createErrorResult`)\n2. Passing the parsed upstream body through at the relevant error sites","comments":[],"createdAt":"2026-04-28T10:04:16Z","labels":[],"number":1718,"title":"[Feature] expose upstream error details in client-facing error responses","url":"https://github.com/diegosouzapw/OmniRoute/issues/1718"}
|
||||
1
_ideia/_fetch/1731.json
Normal file
1
_ideia/_fetch/1731.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjE0NDAzMDM=","is_bot":false,"login":"matteoantoci","name":"Matteo Antoci"},"body":"## Problem\n\nWhen a combo uses the `auto` strategy, targets are reordered by score. If the top-scored targets all share the same provider (e.g., 4 opencode-go models), a single provider quota exhaustion forces the combo to burn through every same-provider target one by one — each cycling through all accounts getting 429 — before reaching a cross-provider fallback (e.g., glm).\n\nObserved behavior: opencode-go quota exhausted → combo spent ~5 minutes cycling through mimo → kimi → qwen → deepseek (all opencode-go), each spending 30-60s on account fallback loops returning 429, before finally trying glm.\n\nThe auto-selection algorithm has no awareness that all top-scored targets share the same provider, so cross-provider diversity is zero in the fallback chain.\n\n## Expected Behavior\n\nBefore: opencode-go/mimo (429) → opencode-go/kimi (429) → opencode-go/qwen (429) → opencode-go/deepseek (429) → glm/glm-5.1 (success) — **~5 minutes**\n\nAfter: opencode-go/mimo (429) → skip kimi/qwen/deepseek → glm/glm-5.1 (success) — **~10 seconds**\n\n## Root Cause\n\nTwo issues compound:\n\n1. **`isModelAvailable` pre-check doesn't catch quota exhaustion** — when all accounts return 429 \"Subscription quota exceeded\", the account-level cooldown is only 3-5s (base cooldown for OAuth/API key profiles). By the time the next combo target is evaluated, the cooldown has expired and the account appears available again.\n\n2. **429 is excluded from the provider circuit breaker** — `PROVIDER_FAILURE_ERROR_CODES = {408, 500, 502, 503, 504}` at `accountFallback.ts:59`. Even hundreds of consecutive 429s won't open the provider breaker, so there's no durable cross-request backoff for rate-limited providers.\n\n<details><summary>Log evidence (14:00-14:02 UTC, 2026-04-28)</summary>\n\n```\n14:00:30 Model opencode-go/mimo-v2.5-pro failed, trying next (429)\n14:00:31 Trying model 2/5: opencode-go/kimi-k2.6\n14:01:18 Model opencode-go/kimi-k2.6 failed, trying next (429)\n14:01:18 Trying model 3/5: opencode-go/qwen3.6-plus\n14:02:10 Model opencode-go/qwen3.6-plus succeeded (146978ms, 1 fallbacks)\n```\n\nAll accounts returned 429 \"Subscription quota exceeded\" for each model in sequence. Total cascade: ~2 minutes before a working path was found.\n\n</details>\n\n<details><summary>Additional evidence: overnight harness (2026-04-29)</summary>\n\n```\n03:40:50 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:40:56 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:03 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:09 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n03:51:15 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n...\n06:48:39 429 mimo-v2.5-pro \"Subscription quota exceeded\"\n```\n\nAll 429s are from the same provider. The combo never reached the cross-provider fallback (glm). Every request cycled through all 3 accounts × all opencode-go targets before giving up.\n\n</details>","comments":[],"createdAt":"2026-04-28T15:31:43Z","labels":[],"number":1731,"title":"[Feature] (combo): provider-level exhaustion tracking to skip same-provider targets","url":"https://github.com/diegosouzapw/OmniRoute/issues/1731"}
|
||||
1
_ideia/_fetch/1735.json
Normal file
1
_ideia/_fetch/1735.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjQwODM4MTI=","is_bot":false,"login":"apoapostolov","name":"Apostol Apostolov"},"body":"## Problem\nCombo building needs a quota-aware ranking rule that favors models whose remaining quota can last through the rest of the current quota window.\n\n## Proposed rule: Highest Remaining Quota Rate\nFor any service with a fixed quota window:\n\n- Let `remaining_quota` be the fraction of quota still available, normalized to `0..1`.\n- Let `remaining_days` be the time left in the current quota window, expressed in days.\n- Compute `remaining_quota_rate = remaining_quota / remaining_days`.\n\nFor weekly quotas:\n\n- The expected pacing threshold is `1/7` quota per day.\n- Prefer models where `remaining_quota_rate > 1/7`.\n- If `remaining_quota_rate <= 1/7`, stop using that model in active combos until it recovers above the threshold.\n\n## Example\nIf a model has `60%` of its weekly quota left and `2d 10hr` remaining:\n\n- `remaining_days = 2.4167`\n- `remaining_quota_rate = 0.60 / 2.4167 = 0.248/day`\n- Since `0.248 > 1/7`, the model should remain eligible and be preferred over lower-rate options.\n\n## Expected behavior\n- Combos should rank models using the `remaining_quota_rate` rule whenever a quota window is known.\n- Models above the threshold should be preferred or retained.\n- Models at or below the threshold should be dropped from active use.\n- The rule should be applied consistently during combo evaluation and refresh.\n\n## Notes\n- If a quota is measured in calls instead of percentage, normalize it to a fraction of the current window before applying the rule.\n- The same rule should generalize to other quota windows by using `1 / window_length_days` as the threshold.\n","comments":[],"createdAt":"2026-04-28T17:54:35Z","labels":[],"number":1735,"title":"[Feature] Combo Builder: quota-aware Highest Remaining Quota rule","url":"https://github.com/diegosouzapw/OmniRoute/issues/1735"}
|
||||
1
_ideia/_fetch/1736.json
Normal file
1
_ideia/_fetch/1736.json
Normal file
@@ -0,0 +1 @@
|
||||
{"author":{"id":"MDQ6VXNlcjQwODM4MTI=","is_bot":false,"login":"apoapostolov","name":"Apostol Apostolov"},"body":"## Problem\nThe /Home dashboard should be customizable so users can choose which widgets appear there and in what order. Right now the home page is too fixed, and some widgets are only useful for specific workflows.\n\n## Request\nAdd a /Home customization layer that lets users:\n- Show or hide individual widgets on /Home.\n- Reorder widgets, including pinning a widget to the top.\n- Reuse blocks from other pages where it makes sense, instead of forcing /Home to be a fixed layout.\n\n## Examples\n- Turn off the Onboarding widget after setup.\n- Hide Providers Status when it is not useful for the current workflow.\n- Pin the Limits and Quotas block to the top when tracking monthly usage for GLM, Codex, Claude, and Copilot.\n\n## Expected behavior\n- Widget visibility should be persisted per user.\n- Widget order should be persistent across refreshes and restarts.\n- The /Home page should still have a sensible default layout for first-time users.\n\n## Notes\n- The goal is not a fully freeform page builder. A controlled widget picker and ordering system is enough.\n- This should probably apply only to /Home at first, not every dashboard page.\n","comments":[],"createdAt":"2026-04-28T18:03:58Z","labels":[],"number":1736,"title":"[Feature] Dashboard customization for /Home","url":"https://github.com/diegosouzapw/OmniRoute/issues/1736"}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user