mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Merge remote-tracking branch 'origin/main' into feat/desktop-remote-ssh-current
# Conflicts: # apps/desktop/electron/connection-config.test.ts # apps/desktop/electron/connection-config.ts # apps/desktop/electron/main.ts
This commit is contained in:
commit
25f4ba7b3c
213 changed files with 10221 additions and 1139 deletions
2
.github/actions/detect-changes/action.yml
vendored
2
.github/actions/detect-changes/action.yml
vendored
|
|
@ -7,7 +7,7 @@ description: >-
|
|||
|
||||
inputs:
|
||||
github-token:
|
||||
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
|
||||
description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow.
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
|
||||
|
|
|
|||
59
.github/actions/get-app-token/action.yml
vendored
Normal file
59
.github/actions/get-app-token/action.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Get GitHub App Token
|
||||
description: >-
|
||||
Mint a short-lived (1-hour) installation access token from the repo's
|
||||
GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get
|
||||
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
|
||||
and are scoped to the App's installation permissions, not a user account.
|
||||
|
||||
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
|
||||
this happens on fork PRs where repo secrets are unavailable. The fallback
|
||||
ensures classification, timings, and review comments still work on
|
||||
forks (with the lower GITHUB_TOKEN rate limit).
|
||||
|
||||
Composite actions cannot access the secrets context directly, so the
|
||||
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
|
||||
as inputs. When both are empty (fork PRs), the fallback fires.
|
||||
|
||||
inputs:
|
||||
client-id:
|
||||
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
private-key:
|
||||
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
outputs:
|
||||
token:
|
||||
description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks.
|
||||
value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Check if App credentials exist
|
||||
id: check
|
||||
shell: bash
|
||||
env:
|
||||
CLIENT_ID: ${{ inputs.client-id }}
|
||||
run: |
|
||||
if [ -n "$CLIENT_ID" ]; then
|
||||
echo "has_app=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has_app=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create GitHub App token
|
||||
id: app-token
|
||||
if: steps.check.outputs.has_app == 'true'
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
client-id: ${{ inputs.client-id }}
|
||||
private-key: ${{ inputs.private-key }}
|
||||
|
||||
- name: Fall back to GITHUB_TOKEN
|
||||
id: fallback
|
||||
if: steps.check.outputs.has_app != 'true'
|
||||
shell: bash
|
||||
run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT"
|
||||
147
.github/workflows/ci.yml
vendored
147
.github/workflows/ci.yml
vendored
|
|
@ -17,7 +17,7 @@ on:
|
|||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment)
|
||||
pull-requests: write # needed by lint (PR comment) + supply-chain review_status
|
||||
actions: read # needed by osv-scanner (SARIF upload)
|
||||
security-events: write # needed by osv-scanner (SARIF upload)
|
||||
packages: write # needed by docker build
|
||||
|
|
@ -49,13 +49,19 @@ jobs:
|
|||
event_name: ${{ github.event_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Detect affected areas
|
||||
id: classify
|
||||
uses: ./.github/actions/detect-changes
|
||||
with:
|
||||
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
|
||||
# the built-in read-only token so classification still works there.
|
||||
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
|
|
@ -73,11 +79,10 @@ jobs:
|
|||
lint:
|
||||
name: Python lints
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true'
|
||||
if: needs.detect.outputs.python == 'true'
|
||||
uses: ./.github/workflows/lint.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
js-tests:
|
||||
|
|
@ -87,6 +92,12 @@ jobs:
|
|||
uses: ./.github/workflows/js-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-desktop:
|
||||
name: Desktop E2E
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/e2e-desktop.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
needs: detect
|
||||
|
|
@ -138,12 +149,20 @@ jobs:
|
|||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true')
|
||||
uses: ./.github/workflows/supply-chain-audit.yml
|
||||
with:
|
||||
event_name: ${{ needs.detect.outputs.event_name }}
|
||||
scan: ${{ needs.detect.outputs.scan == 'true' }}
|
||||
deps: ${{ needs.detect.outputs.deps == 'true' }}
|
||||
|
||||
review-labels:
|
||||
name: Review label gate
|
||||
needs: detect
|
||||
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true')
|
||||
uses: ./.github/workflows/review-labels.yml
|
||||
with:
|
||||
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
|
||||
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
|
||||
secrets: inherit
|
||||
|
||||
|
|
@ -152,19 +171,99 @@ jobs:
|
|||
uses: ./.github/workflows/osv-scanner.yml
|
||||
secrets: inherit
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Live-updating PR review comment.
|
||||
#
|
||||
# A single ``comment-live`` job polls the GitHub Actions API every 15s
|
||||
# for job statuses in this run, re-assembles the review comment from
|
||||
# whatever results are available, and upserts it via the
|
||||
# ``<!-- hermes-ci-review-bot -->`` marker.
|
||||
#
|
||||
# The poller exits when all non-infra jobs are completed (or on
|
||||
# timeout). ci-timings' review_status is picked up automatically when
|
||||
# its artifact becomes available — the poller downloads and merges it.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
comment-live:
|
||||
name: CI review comment (live)
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Run live comment poller
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_RUN_ID: ${{ github.run_id }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
# Commit info for the review comment header.
|
||||
COMMIT_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }}
|
||||
COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }}
|
||||
# Structured review statuses from workflow_call jobs.
|
||||
# Each job outputs a JSON array of {source, results: [...]} objects
|
||||
# that the assembler renders directly — no hardcoded job-name
|
||||
# matching. We merge all available outputs into one array.
|
||||
REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# REVIEW_STATUSES is a JSON array of strings (some may be empty
|
||||
# when a job was skipped). Parse each string and merge into one
|
||||
# flat array for the assembler.
|
||||
python3 - <<'PYEOF'
|
||||
import json, os, sys
|
||||
|
||||
raw = os.environ.get("REVIEW_STATUSES", "")
|
||||
merged = []
|
||||
if raw:
|
||||
try:
|
||||
arr = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
arr = []
|
||||
for item in arr:
|
||||
if not item:
|
||||
continue
|
||||
try:
|
||||
statuses = json.loads(item)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(statuses, list):
|
||||
merged.extend(statuses)
|
||||
|
||||
# Write merged array to a temp file the poller reads.
|
||||
with open("/tmp/review_statuses.json", "w") as f:
|
||||
json.dump(merged, f)
|
||||
print(f"Merged {len(merged)} review status entries")
|
||||
PYEOF
|
||||
|
||||
python3 scripts/ci/live_comment.py \
|
||||
--interval 15 \
|
||||
--timeout 2100 \
|
||||
--review-statuses-file /tmp/review_statuses.json
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Gate: runs after everything. ``if: always()`` ensures it reports a
|
||||
# status even when some deps were skipped. Only actual ``failure``
|
||||
# results cause it to fail; ``skipped`` is treated as success.
|
||||
#
|
||||
# Branch protection should require ONLY this check.
|
||||
#
|
||||
# Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so
|
||||
# the live comment poller can list failed jobs in the PR comment.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
all-checks-pass:
|
||||
name: All required checks pass
|
||||
needs:
|
||||
- detect
|
||||
- tests
|
||||
- lint
|
||||
- js-tests
|
||||
- e2e-desktop
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
|
|
@ -172,20 +271,30 @@ jobs:
|
|||
- lockfile-diff
|
||||
- docker-lint
|
||||
- supply-chain
|
||||
- review-labels
|
||||
- osv-scanner
|
||||
# comment-live is a polling job — it doesn't block the gate.
|
||||
# we don't require docker to pass rn because it's so slow lol
|
||||
# - docker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
needs-json: ${{ steps.evaluate.outputs.needs-json }}
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
id: evaluate
|
||||
env:
|
||||
NEEDS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
echo "$NEEDS" | python3 -c "
|
||||
import json, sys
|
||||
needs = json.load(sys.stdin)
|
||||
# Emit compact {job_name: result} for the comment assembler.
|
||||
compact = {name: info['result'] for name, info in needs.items()}
|
||||
print(f'needs-json={json.dumps(compact)}')
|
||||
with open('$GITHUB_OUTPUT', 'a') as f:
|
||||
f.write(f'needs-json={json.dumps(compact)}\n')
|
||||
failed = [name for name, info in needs.items() if info['result'] == 'failure']
|
||||
for name, info in sorted(needs.items()):
|
||||
result = info['result']
|
||||
|
|
@ -202,6 +311,9 @@ jobs:
|
|||
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
#
|
||||
# The live comment poller picks up ci-timings' completion automatically —
|
||||
# it reads review-status.json from the artifact when the job finishes.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
|
|
@ -213,6 +325,13 @@ jobs:
|
|||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
|
@ -229,24 +348,28 @@ jobs:
|
|||
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
|
||||
# the built-in read-only token so the timings API read still works
|
||||
# there instead of hard-failing this advisory job on every fork PR.
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--output ci-timings-report.html \
|
||||
--json-out ci-timings.json \
|
||||
--summary-out ci-timings-summary.md
|
||||
--summary-out ci-timings-summary.md \
|
||||
--review-status-out review-status.json
|
||||
|
||||
- name: Upload HTML report
|
||||
- name: Upload HTML report + review status
|
||||
# Advisory report — artifact-service blips must not fail the job.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-artifact
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: ci-timings-report.html
|
||||
path: |
|
||||
ci-timings-report.html
|
||||
review-status.json
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
|
|
|
|||
18
.github/workflows/contributor-check.yml
vendored
18
.github/workflows/contributor-check.yml
vendored
|
|
@ -2,6 +2,10 @@ name: Contributor Attribution Check
|
|||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review status objects"
|
||||
value: ${{ jobs.check-attribution.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -10,12 +14,15 @@ jobs:
|
|||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.check-emails.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git log
|
||||
|
||||
- name: Check for unmapped contributor emails
|
||||
id: check-emails
|
||||
run: |
|
||||
# Get the merge base between this PR and main
|
||||
MERGE_BASE=$(git merge-base origin/main HEAD)
|
||||
|
|
@ -25,6 +32,7 @@ jobs:
|
|||
|
||||
if [ -z "$NEW_EMAILS" ]; then
|
||||
echo "No new commits to check."
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
|
@ -67,6 +75,16 @@ jobs:
|
|||
echo ""
|
||||
echo "To find the GitHub username for an email:"
|
||||
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
|
||||
|
||||
# Emit review_status for unmapped emails
|
||||
DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //')
|
||||
HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"<email>": "<github-username>",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n'
|
||||
REVIEW_STATUS=$(jq -nc \
|
||||
--arg detail "$DETAIL" \
|
||||
--arg how_to_fix "$HOW_TO_FIX" \
|
||||
'[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]')
|
||||
echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All contributor emails are mapped."
|
||||
|
|
|
|||
11
.github/workflows/deploy-site.yml
vendored
11
.github/workflows/deploy-site.yml
vendored
|
|
@ -56,6 +56,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
|
@ -73,8 +80,8 @@ jobs:
|
|||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
run: |
|
||||
|
|
|
|||
212
.github/workflows/e2e-desktop.yml
vendored
Normal file
212
.github/workflows/e2e-desktop.yml
vendored
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
name: E2E Desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: e2e-desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# ── System deps for Electron on headless Ubuntu ───────────────────
|
||||
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
|
||||
# install-deps covers browsers; for Electron we install the apt
|
||||
# packages directly.
|
||||
- name: Install system dependencies for Electron
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
xvfb \
|
||||
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
|
||||
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
|
||||
|
||||
# ── Node ───────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Full npm ci (not --ignore-scripts): electron's postinstall
|
||||
# downloads the binary we launch, and node-pty's native build is
|
||||
# needed for the terminal pane.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
|
||||
# ── Python (for the hermes serve backend) ──────────────────────────
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Install Python dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
# ── Build desktop app ─────────────────────────────────────────────
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
# ── Restore visual baseline screenshots from main ──────────────────
|
||||
# Baselines are generated on main (via --update-snapshots) and cached.
|
||||
# On PRs, we restore them so toHaveScreenshot has something to compare
|
||||
# against. The cache key is keyed on the desktop source files so a
|
||||
# UI change naturally invalidates it — but we fall back to the main
|
||||
# cache to avoid cold starts on unrelated PRs.
|
||||
- name: Restore visual baseline screenshots
|
||||
id: restore-baselines
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
visual-baselines-main
|
||||
|
||||
# ── Run Playwright E2E under xvfb ─────────────────────────────────
|
||||
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
|
||||
# window always has a consistent viewport for screenshot comparison.
|
||||
# On main, we run with --update-snapshots to generate baselines.
|
||||
- name: Run Playwright E2E tests
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "On main — generating/updating baseline screenshots"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list --update-snapshots
|
||||
else
|
||||
echo "On PR — comparing against cached baselines"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list
|
||||
fi
|
||||
env:
|
||||
CI: "true"
|
||||
# Ensure no real API keys leak into the test env.
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
# ── Save updated baselines to cache (main only) ───────────────────
|
||||
- name: Save updated baselines to cache
|
||||
if: github.ref_name == 'main' && always()
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-main
|
||||
|
||||
# ── Upload Playwright report (HTML + traces) ──────────────────────
|
||||
- name: Upload Playwright report
|
||||
id: upload-report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-${{ github.sha }}
|
||||
path: apps/desktop/playwright-report
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload test results (screenshots, traces, diffs) ───────────────
|
||||
- name: Upload test results
|
||||
id: upload-results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-test-results-${{ github.sha }}
|
||||
path: apps/desktop/test-results
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload just the visual diffs (small, fast to review) ──────────
|
||||
- name: Upload visual diffs
|
||||
id: upload-diffs
|
||||
if: always() && github.ref_name != 'main'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-diffs-${{ github.sha }}
|
||||
path: |
|
||||
apps/desktop/test-results/**/*-diff.png
|
||||
apps/desktop/test-results/**/*-actual.png
|
||||
apps/desktop/test-results/**/*-expected.png
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── Generate step summary with visual diff info ───────────────────
|
||||
# Parse the JSON report + scan for diff images, then post a summary
|
||||
# to the GitHub Actions step output so reviewers can see what changed
|
||||
# without downloading artifacts. Runs AFTER uploads so it can link
|
||||
# the artifact download URLs from their step outputs.
|
||||
- name: Generate visual diff summary
|
||||
if: always()
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
|
||||
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
|
||||
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
|
||||
run: |
|
||||
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Count diff images (playwright writes *-diff.png on mismatch)
|
||||
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
|
||||
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$DIFF_COUNT" -eq 0 ]; then
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# List each diff image with a link to the artifact
|
||||
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
|
||||
base=$(echo "$diff" | sed 's/-diff\.png$//')
|
||||
test_name=$(basename "$base")
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$RESULTS_URL" ]; then
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$REPORT_URL" ]; then
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$DIFFS_URL" ]; then
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Also parse the JSON report for pass/fail counts
|
||||
if [ -f playwright-report/results.json ]; then
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
node -e "
|
||||
const r = require('./playwright-report/results.json');
|
||||
const stats = r.stats || {};
|
||||
console.log('| Status | Count |');
|
||||
console.log('|--------|-------|');
|
||||
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
|
||||
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
|
||||
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
|
||||
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
|
||||
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
fi
|
||||
12
.github/workflows/history-check.yml
vendored
12
.github/workflows/history-check.yml
vendored
|
|
@ -15,6 +15,10 @@ name: History Check
|
|||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON array of review_status objects for the synthesizer."
|
||||
value: ${{ jobs.check-common-ancestor.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -23,18 +27,23 @@ jobs:
|
|||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
review_status: ${{ steps.merge-base-check.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # full history both sides for merge-base
|
||||
|
||||
- name: Reject PRs with no common ancestor on main
|
||||
- id: merge-base-check
|
||||
name: Reject PRs with no common ancestor on main
|
||||
run: |
|
||||
# `git merge-base` exits non-zero AND prints nothing when the two
|
||||
# commits share no ancestor. We check both conditions explicitly
|
||||
# so the failure message is clear regardless of which signal fires
|
||||
# first.
|
||||
if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then
|
||||
STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]'
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
echo ""
|
||||
echo "::error::This PR has no common ancestor with main."
|
||||
echo ""
|
||||
|
|
@ -56,3 +65,4 @@ jobs:
|
|||
exit 1
|
||||
fi
|
||||
echo "::notice::Common ancestor with main: $BASE"
|
||||
echo "review_status=[]" >> "$GITHUB_OUTPUT"
|
||||
|
|
|
|||
13
.github/workflows/js-autofix.yml
vendored
13
.github/workflows/js-autofix.yml
vendored
|
|
@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting
|
|||
# auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint
|
||||
# check in typecheck.yml fails only when un-fixable errors remain.
|
||||
#
|
||||
# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike
|
||||
# NOTE: App token pushes DO trigger further workflow runs (unlike
|
||||
# secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }})
|
||||
# with cancel-in-progress: true prevents an infinite loop — a re-triggered
|
||||
# run cancels the in-flight one, and since the second run finds no new fixes
|
||||
|
|
@ -128,6 +128,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Download patch
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
|
|
@ -170,7 +177,7 @@ jobs:
|
|||
|
||||
- name: Create/update PR and enable auto-merge
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
BOT_BRANCH: bot/js-autofix
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -193,7 +200,7 @@ jobs:
|
|||
|
||||
- name: Wait for merge, auto-close on failure or stale
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
START_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
|
|||
81
.github/workflows/label-rerun.yml
vendored
Normal file
81
.github/workflows/label-rerun.yml
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
name: Label rerun
|
||||
|
||||
# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in
|
||||
# the latest CI run. This re-evaluates ``review-labels`` (which now sees the
|
||||
# label) and GitHub automatically reruns dependent jobs (``comment-live``,
|
||||
# ``all-checks-pass``) — so the review comment gets updated too.
|
||||
#
|
||||
# If the CI run is still in progress when the label is added, we wait for it
|
||||
# to finish before rerunning (``gh run rerun`` only works on completed runs).
|
||||
# The wait can be long (20+ min for a full CI run), but it's better than
|
||||
# silently failing and leaving the reviewer stuck.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: read
|
||||
|
||||
concurrency:
|
||||
group: label-rerun-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rerun-review-labels:
|
||||
name: Rerun review-labels job
|
||||
if: github.event.label.name == 'ci-reviewed'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Wait for CI run to finish, then rerun failed jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Find the latest CI run for this PR's head SHA.
|
||||
RUN_ID=$(gh run list \
|
||||
--repo "$REPO" \
|
||||
--commit "$HEAD_SHA" \
|
||||
--workflow ci.yml \
|
||||
--limit 1 \
|
||||
--json databaseId,status \
|
||||
--jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true)
|
||||
|
||||
if [ -z "$RUN_ID" ]; then
|
||||
echo "No CI run found for this PR — nothing to rerun."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Split "RUN_ID STATUS" into two vars.
|
||||
RUN_ID="${RUN_ID%% *}"
|
||||
STATUS="${RUN_ID##* }"
|
||||
|
||||
echo "Latest CI run: $RUN_ID (status: $STATUS)"
|
||||
|
||||
# If the run is still in progress, wait for it to finish.
|
||||
# gh run rerun only works on completed runs — if we try while it's
|
||||
# running, GitHub rejects with "cannot be rerun; This workflow is
|
||||
# already running".
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is $STATUS — waiting for completion (this may take a while)..."
|
||||
# gh run watch --exit-status exits non-zero if the run fails,
|
||||
# which is expected (the label gate fails). Don't let that kill
|
||||
# the workflow — we WANT to rerun failed jobs.
|
||||
timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true
|
||||
|
||||
# Verify it's actually completed now.
|
||||
STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown")
|
||||
if [ "$STATUS" != "completed" ]; then
|
||||
echo "Run is still $STATUS after wait — giving up."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Run completed. Rerunning all failed jobs..."
|
||||
gh run rerun "$RUN_ID" --repo "$REPO" --failed || true
|
||||
echo "Done. GitHub will rerun review-labels and all dependent jobs."
|
||||
126
.github/workflows/lint.yml
vendored
126
.github/workflows/lint.yml
vendored
|
|
@ -2,11 +2,14 @@ name: Lint (ruff + ty)
|
|||
|
||||
# Two things here:
|
||||
# 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch.
|
||||
# Posts a Markdown summary and a PR comment. Exit zero always.
|
||||
# Writes a Markdown summary to the run page. Exit zero always.
|
||||
# 2. Blocking ``ruff check .`` — enforces the explicit rules in
|
||||
# ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge.
|
||||
# Separate job so the advisory diff still runs and posts even when
|
||||
# enforcement fails.
|
||||
# Separate job so the advisory diff still runs even when enforcement
|
||||
# fails.
|
||||
#
|
||||
# CI-sensitive file review was previously here as a ``ci-review`` job but
|
||||
# has moved to ``review-labels.yml`` so it can be rerun independently.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
|
@ -15,14 +18,9 @@ on:
|
|||
description: The event name from the calling orchestrator (pull_request or push).
|
||||
type: string
|
||||
required: true
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # needed to post/update PR comments
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
|
|
@ -162,115 +160,3 @@ jobs:
|
|||
|
||||
- name: Run footgun checker
|
||||
run: python scripts/check-windows-footguns.py --all
|
||||
|
||||
ci-review:
|
||||
# Require explicit maintainer review when CI-sensitive files change:
|
||||
# eslint config, workflow YAMLs, or composite actions. These files
|
||||
# influence what code the js-autofix job executes and pushes to
|
||||
# main, so a malicious PR could inject arbitrary code via a custom eslint
|
||||
# rule's `fix` function. The label gate ensures a human reviews before
|
||||
# merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml.
|
||||
name: CI-sensitive file review
|
||||
if: inputs.event_name == 'pull_request' && inputs.ci_review
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Require ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
|
||||
# read-only on forks) so the gate works on fork PRs; fall back to it
|
||||
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
|
||||
# "label absent" rather than hard-failing the step.
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ci-reviewed label present."
|
||||
exit 0
|
||||
fi
|
||||
echo "reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# On failure: find the bot's previous comment and edit it, or create
|
||||
# a new one if none exists. Using an HTML comment marker so we can
|
||||
# locate it reliably across runs without parsing the body text.
|
||||
# Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API
|
||||
# call would fail. The label gate still holds via the step below.
|
||||
- name: Post or update review warning
|
||||
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
BODY="${MARKER}
|
||||
## ⚠️ CI-sensitive file review required
|
||||
|
||||
This PR changes CI-sensitive files (eslint config, workflow YAMLs,
|
||||
or composite actions). These files influence what code the
|
||||
js-autofix job executes and pushes to main.
|
||||
|
||||
A maintainer should verify:
|
||||
- no new eslint rules with custom \`fix\` functions that write outside linted paths,
|
||||
- no workflow changes that widen permissions or remove guards,
|
||||
- no composite action changes that alter what gets executed.
|
||||
|
||||
After review, add the \`ci-reviewed\` label and re-run this check."
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
else
|
||||
gh pr comment "$PR" --body "$BODY"
|
||||
fi
|
||||
|
||||
# Fail the job when the label is missing — always runs (including
|
||||
# fork PRs) so the security gate holds even when the comment step
|
||||
# was skipped above.
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label."
|
||||
exit 1
|
||||
|
||||
# On success: if a previous warning comment exists, edit it to show
|
||||
# the review passed so the PR doesn't have a stale ⚠️ sitting around.
|
||||
# Skipped on fork PRs — no comment was ever posted to update.
|
||||
- name: Update previous warning to passed
|
||||
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
MARKER="<!-- ci-review-bot -->"
|
||||
|
||||
# Find an existing comment with our marker.
|
||||
COMMENT_ID=$(gh api \
|
||||
"repos/${{ github.repository }}/issues/${PR}/comments" \
|
||||
--paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ -n "$COMMENT_ID" ]; then
|
||||
BODY="${MARKER}
|
||||
## ✅ CI-sensitive file review passed
|
||||
|
||||
The \`ci-reviewed\` label is present on this PR."
|
||||
|
||||
gh api --method PATCH \
|
||||
"repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \
|
||||
-f body="$BODY"
|
||||
fi
|
||||
|
|
|
|||
81
.github/workflows/lockfile-diff.yml
vendored
81
.github/workflows/lockfile-diff.yml
vendored
|
|
@ -7,22 +7,25 @@ name: Lockfile diff
|
|||
# the ``packages`` map at the merge base and at HEAD and set-diffs the
|
||||
# {install path: version} maps instead.
|
||||
#
|
||||
# The comment is upserted: the script embeds a hidden HTML marker and the
|
||||
# workflow PATCHes the existing comment when one is found, so a PR gets
|
||||
# exactly one lockfile-diff comment that tracks the latest push instead
|
||||
# of a stack of stale ones. When a later push reverts all lockfile
|
||||
# changes, the comment is updated to say so (deleting it would be more
|
||||
# surprising than telling the reviewer it's resolved).
|
||||
# The semantic diff is exposed as a workflow_call output ``review_status``
|
||||
# (a JSON array in the unified status format) and an artifact
|
||||
# (``lockfile-diff`` containing the markdown fragment) for the step
|
||||
# summary.
|
||||
#
|
||||
# Never blocking — this is review signal, not enforcement. Exit is 0 even
|
||||
# when commenting fails (fork PRs get a read-only GITHUB_TOKEN).
|
||||
# Never blocking — this is review signal, not enforcement.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
changed:
|
||||
description: Whether package-lock.json changed relative to the target branch.
|
||||
value: ${{ jobs.diff.outputs.changed }}
|
||||
review_status:
|
||||
description: JSON array of review status objects for the unified PR comment.
|
||||
value: ${{ jobs.diff.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # post/update the diff comment
|
||||
|
||||
concurrency:
|
||||
group: lockfile-diff-${{ github.event.pull_request.number || github.ref }}
|
||||
|
|
@ -33,6 +36,9 @@ jobs:
|
|||
name: package-lock.json semantic diff
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
changed: ${{ steps.diff.outputs.changed }}
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -54,45 +60,36 @@ jobs:
|
|||
--output /tmp/lockfile-diff.md
|
||||
if [ -s /tmp/lockfile-diff.md ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo "## package-lock.json semantic diff"
|
||||
echo ""
|
||||
cat /tmp/lockfile-diff.md
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
: > /tmp/lockfile-diff.md
|
||||
fi
|
||||
|
||||
- name: Post or update PR comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
REPO: ${{ github.repository }}
|
||||
PR: ${{ github.event.pull_request.number }}
|
||||
CHANGED: ${{ steps.diff.outputs.changed }}
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
run: |
|
||||
set -euo pipefail
|
||||
MARKER='<!-- hermes-lockfile-diff -->'
|
||||
CHANGED="${{ steps.diff.outputs.changed }}"
|
||||
STATUS="[]"
|
||||
|
||||
# Find our previous comment (paginated — busy PRs exceed one page).
|
||||
EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \
|
||||
--jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \
|
||||
| head -1 || true)
|
||||
|
||||
if [ "$CHANGED" != "true" ]; then
|
||||
if [ -n "$EXISTING" ]; then
|
||||
# A previous push changed the lockfile but the latest one
|
||||
# doesn't — update the comment rather than leave stale info.
|
||||
printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md
|
||||
else
|
||||
echo "No lockfile changes and no existing comment — nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "Updating existing comment ${EXISTING}"
|
||||
gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
if [ "$CHANGED" = "true" ]; then
|
||||
CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))")
|
||||
STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}"
|
||||
else
|
||||
echo "Creating new comment"
|
||||
gh api "repos/${REPO}/issues/${PR}/comments" \
|
||||
-F body=@/tmp/lockfile-diff.md > /dev/null \
|
||||
|| echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
STATUS="[]"
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload diff artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: lockfile-diff
|
||||
path: /tmp/lockfile-diff.md
|
||||
retention-days: 1
|
||||
overwrite: true
|
||||
|
|
|
|||
139
.github/workflows/osv-scanner.yml
vendored
139
.github/workflows/osv-scanner.yml
vendored
|
|
@ -14,14 +14,14 @@ name: OSV-Scanner
|
|||
# code patterns in PR diffs) by covering the orthogonal "currently-pinned
|
||||
# dep became known-vulnerable" case.
|
||||
#
|
||||
# Steps below are inlined from Google's officially-recommended reusable
|
||||
# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml),
|
||||
# rather than called via `uses:` so we can set a `timeout-minutes` in the
|
||||
# degenerate case where this job hangs.
|
||||
|
||||
# Uses Google's officially-recommended reusable workflow, pinned by SHA.
|
||||
# Findings land in the repo's Security tab (Code Scanning > OSV-Scanner).
|
||||
# fail-on-vuln is disabled so the job does not block merges on pre-existing
|
||||
# vulnerabilities in pinned deps that we may need to patch deliberately.
|
||||
#
|
||||
# The reusable workflow can't emit custom outputs, so a wrapper job
|
||||
# downloads the SARIF result and summarizes the vulnerability count into
|
||||
# a review_status for the unified PR comment.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
|
@ -40,62 +40,85 @@ permissions:
|
|||
jobs:
|
||||
scan:
|
||||
name: Scan lockfiles
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
fail-on-vuln: false
|
||||
|
||||
- name: 'Run scanner'
|
||||
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
emit-status:
|
||||
name: Emit review status
|
||||
runs-on: ubuntu-latest
|
||||
needs: scan
|
||||
if: always()
|
||||
outputs:
|
||||
review_status: ${{ steps.emit.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Download SARIF result
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
# Scan explicit lockfiles rather than recursing, so we only look at
|
||||
# the three sources of truth and skip vendored / test / worktree dirs.
|
||||
scan-args: |-
|
||||
--output=results.json
|
||||
--format=json
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
name: osv-results
|
||||
path: /tmp/osv-results
|
||||
continue-on-error: true
|
||||
|
||||
- name: 'Run osv-scanner-reporter'
|
||||
uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||
with:
|
||||
scan-args: |-
|
||||
--output=results.sarif
|
||||
--new=results.json
|
||||
--gh-annotations=false
|
||||
--fail-on-vuln=false
|
||||
|
||||
# Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF
|
||||
# format to the repository Actions tab.
|
||||
- name: 'Upload artifact'
|
||||
id: 'upload_artifact'
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
|
||||
with:
|
||||
name: OSV Scanner SARIF file
|
||||
path: results.sarif
|
||||
retention-days: 5
|
||||
|
||||
# Upload the results to GitHub's code scanning dashboard.
|
||||
- name: 'Upload to code-scanning'
|
||||
if: ${{ !cancelled() }}
|
||||
uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10
|
||||
with:
|
||||
sarif_file: results.sarif
|
||||
|
||||
- name: 'Print Code Scanning URL'
|
||||
if: ${{ !cancelled() }}
|
||||
- name: Emit review_status
|
||||
id: emit
|
||||
run: |
|
||||
echo "View the OSV-Scanner results in the 'Security' tab, using the following link:"
|
||||
echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner"
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
set -euo pipefail
|
||||
STATUS="[]"
|
||||
|
||||
- name: 'Error troubleshooter'
|
||||
if: ${{ always() && steps.upload_artifact.outcome == 'failure' }}
|
||||
run: |
|
||||
echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow."
|
||||
exit 1
|
||||
if [ -f /tmp/osv-results/osv-results.sarif ]; then
|
||||
# Count vulnerabilities from the SARIF file
|
||||
VULN_COUNT=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
count = 0
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
count += 1
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
message = result.get('message', {}).get('text', '')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}: {message}')
|
||||
print(count)
|
||||
if vulns:
|
||||
print('\n'.join(vulns[:20]), file=sys.stderr)
|
||||
except Exception:
|
||||
print(0)
|
||||
")
|
||||
|
||||
VULN_DETAIL=""
|
||||
if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then
|
||||
VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies")
|
||||
VULN_DETAIL=$(python3 -c "
|
||||
import json, sys
|
||||
try:
|
||||
with open('/tmp/osv-results/osv-results.sarif') as f:
|
||||
data = json.load(f)
|
||||
vulns = []
|
||||
for run in data.get('runs', []):
|
||||
for result in run.get('results', []):
|
||||
rule_id = result.get('ruleId', 'unknown')
|
||||
loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '')
|
||||
vulns.append(f'- {rule_id} in {loc}')
|
||||
print(json.dumps('\n'.join(vulns[:20])))
|
||||
except Exception:
|
||||
print(json.dumps(''))
|
||||
")
|
||||
STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]"
|
||||
else
|
||||
STATUS="[]"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT"
|
||||
|
|
|
|||
92
.github/workflows/review-labels.yml
vendored
Normal file
92
.github/workflows/review-labels.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
name: Review labels
|
||||
|
||||
# Require explicit maintainer review when CI-sensitive files or the MCP
|
||||
# catalog change. Previously this was split across two jobs in two
|
||||
# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and
|
||||
# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on
|
||||
# ``mcp_catalog``). Both checked for their own label.
|
||||
#
|
||||
# Now consolidated: a single ``ci-reviewed`` label covers both. The
|
||||
# comment sections tell the reviewer exactly what to verify per area,
|
||||
# so one label is enough — the human reads the comment, not the label
|
||||
# name.
|
||||
#
|
||||
# Outputs:
|
||||
# ci_reviewed — "true" / "false" / "" (empty when neither lane ran)
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler. See scripts/ci/emit_review_status.py.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ci_review:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
|
||||
type: boolean
|
||||
default: false
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
ci_reviewed:
|
||||
description: Whether the ci-reviewed label is present. Empty when neither input was true.
|
||||
value: ${{ jobs.check.outputs.ci_reviewed }}
|
||||
review_status:
|
||||
description: JSON array of status objects for the review comment assembler.
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read # read PR labels
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Review label gate
|
||||
if: inputs.ci_review || inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
outputs:
|
||||
ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
review_status: ${{ steps.build-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true)
|
||||
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "ci-reviewed label present."
|
||||
echo "ci_reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ci-reviewed label missing."
|
||||
echo "ci_reviewed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Build review_status JSON
|
||||
id: build-status
|
||||
env:
|
||||
CI_REVIEW: ${{ inputs.ci_review }}
|
||||
MCP_CATALOG: ${{ inputs.mcp_catalog }}
|
||||
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARGS=""
|
||||
if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi
|
||||
if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi
|
||||
if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi
|
||||
|
||||
python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||
run: |
|
||||
echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check."
|
||||
exit 1
|
||||
10
.github/workflows/skills-index-freshness.yml
vendored
10
.github/workflows/skills-index-freshness.yml
vendored
|
|
@ -108,10 +108,18 @@ jobs:
|
|||
echo "Summary: ${{ steps.probe.outputs.summary }}"
|
||||
fi
|
||||
|
||||
- name: Get GitHub App token
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
if: steps.probe.outputs.status != 'ok'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
STATUS: ${{ steps.probe.outputs.status }}
|
||||
DETAIL: ${{ steps.probe.outputs.detail }}
|
||||
run: |
|
||||
|
|
|
|||
17
.github/workflows/skills-index.yml
vendored
17
.github/workflows/skills-index.yml
vendored
|
|
@ -24,6 +24,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
|
@ -35,7 +42,7 @@ jobs:
|
|||
|
||||
- name: Build skills index
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: python scripts/build_skills_index.py
|
||||
|
||||
- name: Upload index artifact
|
||||
|
|
@ -54,7 +61,13 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
|
|
|||
177
.github/workflows/supply-chain-audit.yml
vendored
177
.github/workflows/supply-chain-audit.yml
vendored
|
|
@ -10,9 +10,17 @@ name: Supply Chain Audit
|
|||
# advisory-only workflow instead.
|
||||
#
|
||||
# Path-gating is handled centrally by the ``ci.yml`` orchestrator's
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` /
|
||||
# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those
|
||||
# inputs instead of re-computing the diff.
|
||||
# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as
|
||||
# inputs; this workflow's jobs gate on those inputs instead of re-computing
|
||||
# the diff. MCP catalog review was previously here but has moved to
|
||||
# ``review-labels.yml`` so it can be rerun independently.
|
||||
#
|
||||
# Outputs:
|
||||
# review_status — JSON array of status objects consumed by the review
|
||||
# comment assembler (scripts/ci/assemble_review_comment.py).
|
||||
# Each job (``scan``, ``dep-bounds``) emits its own
|
||||
# array; an ``aggregate`` job merges them into the
|
||||
# workflow-level output.
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
|
@ -29,10 +37,10 @@ on:
|
|||
description: Whether pyproject.toml changed.
|
||||
type: boolean
|
||||
required: true
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
required: true
|
||||
outputs:
|
||||
review_status:
|
||||
description: JSON array of review status objects for the review comment assembler.
|
||||
value: ${{ jobs.aggregate.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
|
@ -44,16 +52,25 @@ jobs:
|
|||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Scan diff for critical patterns
|
||||
id: scan
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -61,7 +78,7 @@ jobs:
|
|||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
|
||||
# Added lines only, excluding lockfiles.
|
||||
# Three-dot diff (base...head) diffs from the merge base to HEAD,
|
||||
# Three-point diff (base...head) diffs from the merge base to HEAD,
|
||||
# so only changes introduced by this PR are included — not changes
|
||||
# that landed on main after the PR branched off.
|
||||
DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true)
|
||||
|
|
@ -139,26 +156,41 @@ jobs:
|
|||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post critical finding comment
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
FOUND: ${{ steps.scan.outputs.found }}
|
||||
run: |
|
||||
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
|
||||
This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging.
|
||||
found = os.environ.get("FOUND", "") == "true"
|
||||
|
||||
$(cat /tmp/findings.md)
|
||||
if found:
|
||||
with open("/tmp/findings.md", encoding="utf-8") as f:
|
||||
detail = f.read()
|
||||
status = [{
|
||||
"source": "supply chain",
|
||||
"results": [{
|
||||
"kind": "error",
|
||||
"title": "Critical supply chain risk",
|
||||
"summary": "Critical supply chain risk patterns detected in this PR.",
|
||||
"detail": detail,
|
||||
"how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label."
|
||||
}]
|
||||
}]
|
||||
else:
|
||||
status = []
|
||||
|
||||
---
|
||||
*Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
|
||||
- name: Fail on critical findings
|
||||
if: steps.scan.outputs.found == 'true'
|
||||
run: |
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details."
|
||||
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details."
|
||||
exit 1
|
||||
|
||||
dep-bounds:
|
||||
|
|
@ -166,6 +198,8 @@ jobs:
|
|||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.emit-status.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -188,7 +222,7 @@ jobs:
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Match PyPI dep specs that have >= but no < ceiling.
|
||||
# Match PyPI dep specs that have >= and no < ceiling.
|
||||
# Pattern: "package>=version" without a following ",<" bound.
|
||||
# Excludes git+ URLs (which use commit SHAs) and comments.
|
||||
UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true)
|
||||
|
|
@ -200,26 +234,36 @@ jobs:
|
|||
echo "found=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Post unbounded dep warning
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
- name: Emit review_status
|
||||
id: emit-status
|
||||
if: always()
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
FOUND: ${{ steps.bounds.outputs.found }}
|
||||
run: |
|
||||
BODY="## ⚠️ Unbounded PyPI Dependency Detected
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
|
||||
This PR adds PyPI dependencies without a \`<next_major\` upper bound. Per our [supply chain policy](../blob/main/CONTRIBUTING.md#dependency-pinning-policy-supply-chain-hardening), all PyPI deps must be pinned as \`>=floor,<next_major\`.
|
||||
found = os.environ.get("FOUND", "") == "true"
|
||||
|
||||
**Unbounded specs found:**
|
||||
\`\`\`
|
||||
$(cat /tmp/unbounded.txt)
|
||||
\`\`\`
|
||||
if found:
|
||||
with open("/tmp/unbounded.txt", encoding="utf-8") as f:
|
||||
detail = f.read()
|
||||
status = [{
|
||||
"source": "supply chain",
|
||||
"results": [{
|
||||
"kind": "action_required",
|
||||
"title": "Unbounded PyPI dependencies",
|
||||
"summary": "This PR adds PyPI dependencies without upper bounds.",
|
||||
"detail": detail,
|
||||
"how_to_fix": 'Add a `<next_major` upper bound, e.g. `"package>=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.'
|
||||
}]
|
||||
}]
|
||||
else:
|
||||
status = []
|
||||
|
||||
**Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\`
|
||||
|
||||
---
|
||||
*See PR #2810 and CONTRIBUTING.md for the full policy rationale.*"
|
||||
|
||||
gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(status)}\n")
|
||||
PYEOF
|
||||
|
||||
- name: Fail on unbounded deps
|
||||
if: steps.bounds.outputs.found == 'true'
|
||||
|
|
@ -227,45 +271,36 @@ jobs:
|
|||
echo "::error::PyPI dependencies without upper bounds detected. Add <next_major ceiling per CONTRIBUTING.md policy."
|
||||
exit 1
|
||||
|
||||
mcp-catalog-review:
|
||||
name: MCP catalog security review
|
||||
if: inputs.mcp_catalog
|
||||
aggregate:
|
||||
name: Aggregate review statuses
|
||||
needs: [scan, dep-bounds]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
outputs:
|
||||
review_status: ${{ steps.merge.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
- name: Merge review statuses
|
||||
id: merge
|
||||
env:
|
||||
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
|
||||
# read-only on forks) so the gate works on fork PRs; fall back to it
|
||||
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
|
||||
# "label absent" rather than hard-failing the step.
|
||||
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
|
||||
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
|
||||
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
python3 - <<'PYEOF'
|
||||
import json, os
|
||||
|
||||
BODY="## ⚠️ MCP catalog security review required
|
||||
merged = []
|
||||
for key in ("SCAN_STATUS", "DEP_STATUS"):
|
||||
raw = os.environ.get(key, "")
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(data, list):
|
||||
merged.extend(data)
|
||||
|
||||
This PR changes the bundled MCP catalog or MCP catalog installer code. MCP entries can define local commands that users later install into \`mcp_servers\`, so this needs explicit maintainer review before merge.
|
||||
|
||||
A maintainer should verify:
|
||||
- any new/changed \`optional-mcps/**/manifest.yaml\` command and args are expected,
|
||||
- stdio transports do not use shell+egress/exfiltration payloads,
|
||||
- git install refs are pinned and bootstrap commands are minimal,
|
||||
- requested env vars/secrets match the upstream MCP's documented needs.
|
||||
|
||||
After review, add the \`mcp-catalog-reviewed\` label and re-run this check."
|
||||
|
||||
gh pr comment "$PR" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)"
|
||||
echo "::error::MCP catalog changes require the mcp-catalog-reviewed label."
|
||||
exit 1
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
|
||||
f.write(f"review_status={json.dumps(merged)}\n")
|
||||
PYEOF
|
||||
|
|
|
|||
11
.github/workflows/upload_to_pypi.yml
vendored
11
.github/workflows/upload_to_pypi.yml
vendored
|
|
@ -143,9 +143,16 @@ jobs:
|
|||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Wait for GitHub Release to exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# release.py creates the GitHub Release after pushing the tag,
|
||||
# but this workflow starts from the tag push — wait for it.
|
||||
run: |
|
||||
|
|
@ -171,7 +178,7 @@ jobs:
|
|||
- name: Attach signed artifacts to GitHub Release
|
||||
if: env.skip_sign != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# release.py already created the GitHub Release — just upload
|
||||
# the Sigstore signatures alongside the existing assets.
|
||||
run: >-
|
||||
|
|
|
|||
11
.github/workflows/uv-lockfile-check.yml
vendored
11
.github/workflows/uv-lockfile-check.yml
vendored
|
|
@ -45,6 +45,10 @@ name: uv.lock check
|
|||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: "JSON review status for the review-status aggregator"
|
||||
value: ${{ jobs.check.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -58,6 +62,8 @@ jobs:
|
|||
name: uv lock --check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
review_status: ${{ steps.verify.outputs.review_status }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
|
@ -73,6 +79,7 @@ jobs:
|
|||
# of this file) — failures often mean "your branch is behind main,
|
||||
# rebase and regenerate uv.lock."
|
||||
- name: Verify uv.lock is up-to-date
|
||||
id: verify
|
||||
run: |
|
||||
# uv lock --check re-resolves against PyPI (network). Retry so a
|
||||
# registry blip doesn't read as "lockfile stale". A genuinely stale
|
||||
|
|
@ -117,5 +124,9 @@ jobs:
|
|||
on `main` post-merge.
|
||||
EOF
|
||||
echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first."
|
||||
review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
exit 1
|
||||
fi
|
||||
review_status='[]'
|
||||
echo "review_status=${review_status}" >> "$GITHUB_OUTPUT"
|
||||
|
|
|
|||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -4,6 +4,8 @@
|
|||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
act/
|
||||
.act-sandbox-agent.*
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
|
|
@ -54,6 +56,10 @@ __pycache__/
|
|||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
playwright-report/
|
||||
test-results/
|
||||
# Playwright visual regression baselines — cached from main in CI, not committed
|
||||
*-snapshots/
|
||||
|
||||
# CLI config (may contain sensitive SSH paths)
|
||||
cli-config.yaml
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.2",
|
||||
"version": "0.19.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
|
|
@ -9,7 +9,7 @@
|
|||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"package": "hermes-agent[acp]==0.19.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
"""Surface-agnostic core for the Phase 2b terminal-billing screens.
|
||||
"""Surface-agnostic core for the Phase 2b Remote Spending screens.
|
||||
|
||||
One fetch/parse per concern, consumed identically by the CLI handler
|
||||
(``cli.py::_show_billing``), the TUI JSON-RPC methods
|
||||
|
|
|
|||
|
|
@ -1236,8 +1236,29 @@ def compress_context(
|
|||
# Flush any un-persisted current-turn messages to the OLD
|
||||
# session before ending it, so they survive in the preserved
|
||||
# parent transcript (#47202). (In-place skips this — see above.)
|
||||
#
|
||||
# Pass the already-durable prefix as conversation_history so
|
||||
# the flush skips it by identity (#68196). Preflight
|
||||
# compression runs BEFORE the normal turn flush has stamped
|
||||
# the cold-resumed history dicts with _DB_PERSISTED_MARKER, so
|
||||
# without a boundary _flush_messages_to_session_db treats every
|
||||
# restored row as new and re-appends the whole transcript to
|
||||
# the parent. turn_context anchors _persist_user_message_idx at
|
||||
# the current-turn user message before preflight runs, so
|
||||
# messages[:idx] is exactly the persisted prefix; only the
|
||||
# current turn's new messages get written.
|
||||
current_idx = getattr(agent, "_persist_user_message_idx", None)
|
||||
persisted_history = (
|
||||
messages[:current_idx]
|
||||
if isinstance(current_idx, int)
|
||||
and 0 <= current_idx <= len(messages)
|
||||
else None
|
||||
)
|
||||
try:
|
||||
agent._flush_messages_to_session_db(messages)
|
||||
agent._flush_messages_to_session_db(
|
||||
messages,
|
||||
conversation_history=persisted_history,
|
||||
)
|
||||
except Exception:
|
||||
pass # best-effort — don't block compression on a flush error
|
||||
# Propagate title to the new session with auto-numbering
|
||||
|
|
|
|||
|
|
@ -91,6 +91,11 @@ INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model r
|
|||
# itself, so every exception passes through them, which would make
|
||||
# _hit_local always True and misclassify transient API/network errors as
|
||||
# non-retryable local bugs. (#66267)
|
||||
#
|
||||
# AttributeError is handled separately in the outer except block — it is
|
||||
# ALWAYS a local programming bug when it targets agent attributes (especially
|
||||
# missing methods introduced by a commit splice after an auto-update rewrites
|
||||
# source underneath a live process). See the dedicated guard below. (#68178)
|
||||
_LOCAL_PROCESSING_MODULES = frozenset({
|
||||
"agent_runtime_helpers",
|
||||
"message_content",
|
||||
|
|
@ -719,6 +724,39 @@ def run_conversation(
|
|||
)
|
||||
|
||||
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
|
||||
# ── Code skew guard (#68178) ───────────────────────────────
|
||||
# Check whether the source tree has been updated underneath this
|
||||
# long-lived process. If so, a lazy import can resolve newly-added
|
||||
# symbols against the stale in-memory AIAgent class, producing an
|
||||
# AttributeError that would otherwise retry indefinitely.
|
||||
# Perform the check on every iteration (it is cheap once confirmed).
|
||||
_skew_warning = getattr(agent, "_check_code_skew_before_turn", lambda: None)()
|
||||
if _skew_warning:
|
||||
logger.warning("Code skew detected at API call #%d: %s", api_call_count + 1, _skew_warning)
|
||||
_turn_exit_reason = "code_skew_detected"
|
||||
final_response = (
|
||||
f"I apologize, but the agent has detected that its source code "
|
||||
f"has been updated while running. To avoid compatibility issues, "
|
||||
f"please restart the application. ({_skew_warning})"
|
||||
)
|
||||
messages.append({"role": "assistant", "content": final_response})
|
||||
return finalize_turn(
|
||||
agent,
|
||||
final_response=final_response,
|
||||
api_call_count=api_call_count,
|
||||
interrupted=False,
|
||||
failed=True,
|
||||
messages=messages,
|
||||
conversation_history=conversation_history,
|
||||
effective_task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
user_message=user_message,
|
||||
original_user_message=original_user_message,
|
||||
_should_review_memory=_should_review_memory,
|
||||
_turn_exit_reason=_turn_exit_reason,
|
||||
_pending_verification_response=_pending_verification_response,
|
||||
_pending_verification_response_previewed=_pending_verification_response_previewed,
|
||||
)
|
||||
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
|
||||
agent._checkpoint_mgr.new_turn()
|
||||
|
||||
|
|
@ -5706,7 +5744,21 @@ def run_conversation(
|
|||
|
||||
_is_local_processing_error = _hit_local and not _hit_api
|
||||
|
||||
if _is_local_processing_error:
|
||||
# AttributeError on the agent object is ALWAYS a local bug —
|
||||
# it means the live process is running spliced commits (the
|
||||
# method does not exist on the in-memory AIAgent class but
|
||||
# conversation_loop.py references it). Circuit-break
|
||||
# immediately to avoid burning provider API calls. (#68178)
|
||||
_is_agent_attribute_error = (
|
||||
isinstance(e, AttributeError)
|
||||
and ("run_agent" in tb_module_names or "agent" in tb_module_names)
|
||||
)
|
||||
|
||||
if _is_agent_attribute_error:
|
||||
error_msg = (
|
||||
f"Fatal local code error in API call #{api_call_count}: {str(e)}"
|
||||
)
|
||||
elif _is_local_processing_error:
|
||||
error_msg = (
|
||||
f"Error during local message processing after "
|
||||
f"OpenAI-compatible API call #{api_call_count}: {str(e)}"
|
||||
|
|
@ -5760,13 +5812,22 @@ def run_conversation(
|
|||
# role-alternation invariants.
|
||||
|
||||
# If we're near the limit, break to avoid infinite loops.
|
||||
# Local processing errors are deterministic — stop immediately
|
||||
# rather than retrying until the budget is exhausted.
|
||||
# Local processing errors and agent AttributeError (commit-splice
|
||||
# symptom) are deterministic — stop immediately rather than
|
||||
# retrying until the budget is exhausted. (#68178)
|
||||
if (
|
||||
_is_local_processing_error
|
||||
_is_agent_attribute_error
|
||||
or _is_local_processing_error
|
||||
or api_call_count >= agent.max_iterations - 1
|
||||
):
|
||||
if _is_local_processing_error:
|
||||
if _is_agent_attribute_error:
|
||||
_turn_exit_reason = f"code_skew_attribute_error({error_msg[:80]})"
|
||||
final_response = (
|
||||
f"I apologize, but the agent process has detected a code "
|
||||
f"mismatch (running stale code after an update). "
|
||||
f"Please restart the application. Error: {error_msg}"
|
||||
)
|
||||
elif _is_local_processing_error:
|
||||
_turn_exit_reason = f"local_processing_error({error_msg[:80]})"
|
||||
final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}"
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
|
|||
|
||||
Implementation notes:
|
||||
|
||||
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
|
||||
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
|
||||
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
|
||||
- All per-document state lives in one :class:`_DocState` keyed by
|
||||
absolute path. Freshness is tracked with **document versions**,
|
||||
not timestamps: every didChange bumps ``version``, and each stored
|
||||
push/pull result is tagged with the version it describes. A
|
||||
result is fresh iff its tag >= the version being waited on, so a
|
||||
didChange implicitly invalidates everything older — no clearing,
|
||||
no clock comparisons, no race windows. This is what prevents
|
||||
"ghost diagnostics": a slow server's leftovers from the previous
|
||||
edit can never masquerade as a verdict on the current content.
|
||||
|
||||
- Whole-document sync. Even when the server advertises incremental
|
||||
sync, we send a single ``contentChanges`` entry replacing the
|
||||
|
|
@ -45,6 +51,7 @@ import asyncio
|
|||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
|
|
@ -124,6 +131,40 @@ def _end_position(text: str) -> Dict[str, int]:
|
|||
return {"line": last_line, "character": last_col}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocState:
|
||||
"""Everything the client tracks for one open document.
|
||||
|
||||
``version`` is the LSP document version we last sent (didOpen=0,
|
||||
each didChange +1). It doubles as the freshness token: stored
|
||||
push/pull results are tagged with the version they describe
|
||||
(``push_version`` / ``pull_version``), and a result is *fresh*
|
||||
iff its tag has caught up to ``version``. Bumping the version on
|
||||
didChange therefore invalidates all older results implicitly —
|
||||
no store-clearing, no timestamps.
|
||||
|
||||
``push_version``/``pull_version`` start at -1 = "no data yet".
|
||||
Servers that echo a document version in publishDiagnostics get
|
||||
exact tagging; those that don't are credited with the current
|
||||
version at receipt time (a push observed after we sent the
|
||||
change describes the changed content or newer).
|
||||
"""
|
||||
|
||||
version: int = 0
|
||||
text: str = ""
|
||||
push: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pull: List[Dict[str, Any]] = field(default_factory=list)
|
||||
push_version: int = -1
|
||||
pull_version: int = -1
|
||||
seed_seen: bool = False
|
||||
|
||||
def fresh_push(self, version: Optional[int] = None) -> bool:
|
||||
return self.push_version >= (self.version if version is None else version)
|
||||
|
||||
def fresh_pull(self, version: Optional[int] = None) -> bool:
|
||||
return self.pull_version >= (self.version if version is None else version)
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""Async LSP client tied to one server process and one workspace root.
|
||||
|
||||
|
|
@ -186,18 +227,10 @@ class LSPClient:
|
|||
# is silently dropped by default.
|
||||
}
|
||||
|
||||
# Tracked file state — required for didChange version bumps.
|
||||
self._files: Dict[str, Dict[str, Any]] = {}
|
||||
# Diagnostic stores, keyed by file path (NOT URI).
|
||||
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Per-path "last published" time so wait-for-fresh logic works.
|
||||
self._published: Dict[str, float] = {}
|
||||
# Per-path version of the latest push (matches our didChange
|
||||
# version when the server respects it).
|
||||
self._published_version: Dict[str, int] = {}
|
||||
# First-push seen flag, for typescript-style seed-on-first-push.
|
||||
self._first_push_seen: Set[str] = set()
|
||||
# Per-document state (version, text, diagnostic stores, and
|
||||
# their freshness tags), keyed by absolute file path (NOT URI).
|
||||
# See _DocState for the version-based freshness model.
|
||||
self._docs: Dict[str, _DocState] = {}
|
||||
# Capability registrations — only diagnostic ones are tracked.
|
||||
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
|
|
@ -647,25 +680,25 @@ class LSPClient:
|
|||
if not isinstance(diagnostics, list):
|
||||
diagnostics = []
|
||||
version = params.get("version")
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
|
||||
if self._seed_first_push and path not in self._first_push_seen:
|
||||
# First push: seed without firing the event so a waiter
|
||||
# doesn't resolve on the very first push (which arrives
|
||||
# before the user-triggered didChange could've produced
|
||||
# fresh diagnostics).
|
||||
self._first_push_seen.add(path)
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
doc = self._docs.setdefault(path, _DocState(version=-1))
|
||||
if self._seed_first_push and not doc.seed_seen:
|
||||
# First push: seed the store WITHOUT a freshness tag. It
|
||||
# arrives before the user-triggered didChange could've
|
||||
# produced fresh diagnostics, so it must never satisfy a
|
||||
# waiter — it's baseline data only.
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
return
|
||||
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
self._first_push_seen.add(path)
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
# Tag with the echoed document version when the server provides
|
||||
# one; otherwise credit the current version — a push observed
|
||||
# after we sent the change describes the changed content (or
|
||||
# newer). Note doc.version is -1 for never-opened paths
|
||||
# (e.g. relatedDocuments spillover), keeping them unfresh.
|
||||
doc.push_version = version if isinstance(version, int) else doc.version
|
||||
# Bump the monotonic push counter and wake every waiter. We
|
||||
# keep the Event sticky-set so any wait already in progress
|
||||
# resolves; waiters re-check their predicate after waking and
|
||||
|
|
@ -694,16 +727,16 @@ class LSPClient:
|
|||
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
|
||||
|
||||
uri = file_uri(abs_path)
|
||||
existing = self._files.get(abs_path)
|
||||
doc = self._docs.get(abs_path)
|
||||
|
||||
if existing is not None:
|
||||
if doc is not None and doc.version >= 0:
|
||||
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
|
||||
)
|
||||
new_version = existing["version"] + 1
|
||||
old_text = existing["text"]
|
||||
new_version = doc.version + 1
|
||||
old_text = doc.text
|
||||
content_changes: List[Dict[str, Any]]
|
||||
if self._sync_kind == 2:
|
||||
content_changes = [
|
||||
|
|
@ -724,7 +757,11 @@ class LSPClient:
|
|||
"contentChanges": content_changes,
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": new_version, "text": text}
|
||||
# Bumping the version is the whole invalidation story:
|
||||
# every stored result tagged with an older version is now
|
||||
# stale by definition (see _DocState).
|
||||
doc.version = new_version
|
||||
doc.text = text
|
||||
return new_version
|
||||
|
||||
# First open: didChangeWatchedFiles CREATED + didOpen.
|
||||
|
|
@ -732,12 +769,9 @@ class LSPClient:
|
|||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
|
||||
)
|
||||
# Clear any stale push/pull entries — fresh open should start
|
||||
# from scratch.
|
||||
self._push_diagnostics.pop(abs_path, None)
|
||||
self._pull_diagnostics.pop(abs_path, None)
|
||||
self._published.pop(abs_path, None)
|
||||
self._published_version.pop(abs_path, None)
|
||||
# Fresh doc state — anything stashed under this path by a
|
||||
# pre-open push (relatedDocuments spillover etc.) is discarded.
|
||||
self._docs[abs_path] = _DocState(version=0, text=text)
|
||||
await self._send_notification(
|
||||
"textDocument/didOpen",
|
||||
{
|
||||
|
|
@ -749,7 +783,6 @@ class LSPClient:
|
|||
}
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": 0, "text": text}
|
||||
return 0
|
||||
|
||||
async def save_file(self, path: str) -> None:
|
||||
|
|
@ -769,12 +802,19 @@ class LSPClient:
|
|||
async def _pull_document_diagnostics(self, path: str) -> None:
|
||||
"""Send ``textDocument/diagnostic`` for one file.
|
||||
|
||||
Stores results into :attr:`_pull_diagnostics`. Silently
|
||||
no-ops on errors (server may not support the pull endpoint).
|
||||
Stores results into the doc's pull store, tagged with the
|
||||
document version captured at request send time. If a didChange
|
||||
races past the in-flight request, the version bump makes the
|
||||
stored result stale automatically — no explicit invalidation.
|
||||
Silently no-ops on errors (server may not support the pull
|
||||
endpoint).
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
doc = self._docs.get(abs_path)
|
||||
sent_version = doc.version if doc else -1
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
"textDocument": {"uri": file_uri(os.path.abspath(path))}
|
||||
"textDocument": {"uri": file_uri(abs_path)}
|
||||
}
|
||||
result = await self._send_request_with_retry(
|
||||
"textDocument/diagnostic",
|
||||
|
|
@ -788,7 +828,9 @@ class LSPClient:
|
|||
return
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
self._pull_diagnostics[os.path.abspath(path)] = items
|
||||
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
|
||||
doc.pull = items
|
||||
doc.pull_version = sent_version
|
||||
related = result.get("relatedDocuments")
|
||||
if isinstance(related, dict):
|
||||
for uri, sub in related.items():
|
||||
|
|
@ -796,7 +838,11 @@ class LSPClient:
|
|||
continue
|
||||
sub_items = sub.get("items")
|
||||
if isinstance(sub_items, list):
|
||||
self._pull_diagnostics[uri_to_path(uri)] = sub_items
|
||||
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
|
||||
rel.pull = sub_items
|
||||
# Same send-anchored tagging: fresh only if that
|
||||
# doc hasn't changed since the request went out.
|
||||
rel.pull_version = rel.version
|
||||
|
||||
async def wait_for_diagnostics(
|
||||
self,
|
||||
|
|
@ -804,22 +850,36 @@ class LSPClient:
|
|||
version: int,
|
||||
*,
|
||||
mode: str = "document",
|
||||
) -> None:
|
||||
timeout: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
|
||||
|
||||
``mode`` is ``"document"`` (5s budget, document pulls) or
|
||||
``"full"`` (10s budget, also workspace pulls). Best-effort —
|
||||
returns silently on timeout. Does NOT throw if the server
|
||||
doesn't support pull diagnostics; we still get the push side.
|
||||
``"full"`` (10s budget, also workspace pulls). ``timeout``
|
||||
overrides the mode's default budget when provided — this is
|
||||
how the user's ``lsp.wait_timeout`` config reaches the wait
|
||||
loop (slow servers like tsserver on big projects need more
|
||||
than the 5s default).
|
||||
|
||||
Returns ``True`` when *fresh* diagnostics arrived (a push at
|
||||
or after our didChange, or a pull answered after it) and
|
||||
``False`` on timeout. Callers must treat ``False`` as "no
|
||||
data", NOT as "no errors" — the diagnostic stores may still
|
||||
hold stale entries from the previous edit at that point.
|
||||
Best-effort — never throws if the server doesn't support pull
|
||||
diagnostics; we still get the push side.
|
||||
"""
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
if timeout is not None and timeout > 0:
|
||||
budget = timeout
|
||||
else:
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
deadline = asyncio.get_event_loop().time() + budget
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return
|
||||
return False
|
||||
|
||||
# Concurrent: document pull + push wait.
|
||||
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
|
||||
|
|
@ -838,26 +898,24 @@ class LSPClient:
|
|||
pass
|
||||
|
||||
# If we got a fresh push for our version, we're done.
|
||||
current_v = self._published_version.get(abs_path)
|
||||
if abs_path in self._published and (
|
||||
current_v is None or current_v >= version
|
||||
):
|
||||
return
|
||||
doc = self._docs.get(abs_path)
|
||||
if doc and doc.fresh_push(version):
|
||||
return True
|
||||
|
||||
# Pull may have populated _pull_diagnostics — that's also
|
||||
# success.
|
||||
if abs_path in self._pull_diagnostics:
|
||||
return
|
||||
# Pull may have answered for the current version — that's
|
||||
# also success.
|
||||
if doc and doc.fresh_pull(version):
|
||||
return True
|
||||
|
||||
# Loop until budget runs out.
|
||||
|
||||
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
|
||||
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
baseline = self._push_counter
|
||||
while True:
|
||||
current_v = self._published_version.get(path)
|
||||
if path in self._published and (current_v is None or current_v >= version):
|
||||
doc = self._docs.get(path)
|
||||
if doc and doc.fresh_push(version):
|
||||
# Debounce — wait a tick in case more diagnostics arrive
|
||||
# immediately after. TS often emits in pairs. We
|
||||
# snapshot the counter so we wake on a *new* push, not
|
||||
|
|
@ -888,17 +946,28 @@ class LSPClient:
|
|||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Return current merged + deduped diagnostics for one file.
|
||||
|
||||
Diagnostics from push and pull stores are concatenated and
|
||||
deduplicated by ``(severity, code, message, range)`` content
|
||||
key. Empty list if the server hasn't published anything.
|
||||
|
||||
With ``fresh_only=True``, a store only contributes when its
|
||||
version tag has caught up to the document's current version —
|
||||
stale leftovers from the previous edit cycle are excluded.
|
||||
This is what report paths should use: after an edit, "stale
|
||||
errors" and "no errors" must not be conflated.
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
push = self._push_diagnostics.get(abs_path) or []
|
||||
pull = self._pull_diagnostics.get(abs_path) or []
|
||||
return _dedupe(push, pull)
|
||||
doc = self._docs.get(os.path.abspath(path))
|
||||
if doc is None:
|
||||
return []
|
||||
if fresh_only:
|
||||
return _dedupe(
|
||||
doc.push if doc.fresh_push() else [],
|
||||
doc.pull if doc.fresh_pull() else [],
|
||||
)
|
||||
return _dedupe(doc.push, doc.pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
|
|
|||
|
|
@ -292,7 +292,10 @@ class LSPService:
|
|||
if not self.enabled_for(file_path):
|
||||
return
|
||||
try:
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
|
||||
# Outer join budget must exceed the inner wait budget or a
|
||||
# slow-but-alive server gets falsely marked broken.
|
||||
t = max(8.0, self._wait_timeout + 3.0)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
|
||||
self._delta_baseline[os.path.abspath(file_path)] = diags or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
|
|
@ -341,7 +344,7 @@ class LSPService:
|
|||
|
||||
try:
|
||||
t = timeout if timeout is not None else self._wait_timeout + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
|
||||
except asyncio.TimeoutError as e:
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
|
|
@ -353,6 +356,17 @@ class LSPService:
|
|||
self._mark_broken_for_file(file_path, e)
|
||||
return []
|
||||
|
||||
if diags is None:
|
||||
# The server is alive but never produced diagnostics for the
|
||||
# post-edit content within the wait budget (common for
|
||||
# tsserver on large projects). Report "no data" rather than
|
||||
# whatever stale state is in the stores — surfacing the
|
||||
# previous edit's errors as if they were current is the
|
||||
# ghost-diagnostics bug. The server is NOT marked broken:
|
||||
# slow is not dead, and the next edit may well succeed.
|
||||
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
|
||||
return []
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if delta:
|
||||
baseline = self._delta_baseline.get(abs_path) or []
|
||||
|
|
@ -452,26 +466,43 @@ class LSPService:
|
|||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
if not fresh:
|
||||
# No fresh data for the pre-edit content — an empty baseline
|
||||
# is safe: worst case the delta filter removes less, never
|
||||
# more. Never seed the baseline from stale stores.
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Open + wait for FRESH diagnostics.
|
||||
|
||||
Returns the fresh diagnostic list, or ``None`` when the server
|
||||
never produced post-change data within the wait budget. The
|
||||
distinction matters: ``[]`` means "server checked the new
|
||||
content, it's clean", ``None`` means "no verdict" — the caller
|
||||
must not substitute stale data for either.
|
||||
"""
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return []
|
||||
return None
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.save_file(file_path)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
fresh = await client.wait_for_diagnostics(
|
||||
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return []
|
||||
return None
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
if not fresh:
|
||||
return None
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
ws, gated = resolve_workspace_for_file(file_path)
|
||||
|
|
@ -482,7 +513,7 @@ class LSPService:
|
|||
client = self._clients.get((srv.server_id, ws))
|
||||
if client is None:
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path))
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
|
||||
srv = find_server_for_file(file_path)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ and MoonshotAI/kimi-cli#1595:
|
|||
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||||
the parent. Presence of both causes "type should be defined in anyOf
|
||||
items instead of the parent schema".
|
||||
3. Every object schema must carry a ``required`` array, even an empty one.
|
||||
Standard JSON Schema allows omitting it; Moonshot 400s with
|
||||
"required must be an array".
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
|
|
@ -130,9 +133,32 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
|||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: object schemas must carry a `required` array, even when empty.
|
||||
if repaired.get("type") == "object":
|
||||
repaired = _ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
|
||||
|
||||
Standard JSON Schema lets you omit ``required`` when nothing is required;
|
||||
Moonshot 400s on that ("required must be an array"). Ensure the key is a
|
||||
list. When ``properties`` is known, prune ``required`` entries that don't
|
||||
name a real property — defensive against dangling names, which Moonshot
|
||||
also rejects. Mutates and returns ``node``.
|
||||
"""
|
||||
props = node.get("properties")
|
||||
req = node.get("required")
|
||||
if isinstance(req, list):
|
||||
if isinstance(props, dict):
|
||||
node["required"] = [r for r in req if r in props]
|
||||
else:
|
||||
node["required"] = []
|
||||
return node
|
||||
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
|
|
@ -174,17 +200,18 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
|||
applied. Input is not mutated.
|
||||
"""
|
||||
if not isinstance(parameters, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
|
||||
if not isinstance(repaired, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
# Top-level must be an object schema
|
||||
if repaired.get("type") != "object":
|
||||
repaired["type"] = "object"
|
||||
if "properties" not in repaired:
|
||||
repaired["properties"] = {}
|
||||
_ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
|
|
|
|||
|
|
@ -805,8 +805,19 @@ PLATFORM_HINTS = {
|
|||
),
|
||||
"matrix": (
|
||||
"You are in a Matrix room communicating with your user. "
|
||||
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
||||
"the adapter converts your Markdown to HTML for rich display. "
|
||||
"The adapter converts your Markdown to HTML for rich display — bold, "
|
||||
"italic, inline code, fenced code blocks, headings, bullet and "
|
||||
"numbered lists, blockquotes, and links all render.\n\n"
|
||||
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
|
||||
"Beeper, most mobile apps) do not render HTML tables, so the cells "
|
||||
"collapse into one continuous run of text. Present tabular data as "
|
||||
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
|
||||
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
|
||||
"(- [ ] / - [x]) — they are not converted and appear as literal "
|
||||
"characters.\n\n"
|
||||
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
|
||||
"referencing something with an associated URL (events, sources, "
|
||||
"people), make the name a clickable link.\n\n"
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
||||
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
||||
|
|
|
|||
|
|
@ -53,6 +53,28 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name: str,
|
||||
function_args: dict,
|
||||
effective_task_id: str,
|
||||
) -> None:
|
||||
"""Checkpoint the same workspace path that the file tool will mutate."""
|
||||
file_path = function_args.get("path", "")
|
||||
if not file_path:
|
||||
return
|
||||
|
||||
# File tools resolve relative paths against the task's live/session cwd,
|
||||
# which can differ from the Hermes process cwd (notably in Docker). Resolve
|
||||
# through that same path pipeline before asking the checkpoint manager to
|
||||
# discover the project root.
|
||||
from tools.file_tools import _resolve_path_for_task
|
||||
|
||||
resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default")
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path))
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
|
||||
|
||||
def _budget_for_agent(agent) -> BudgetConfig:
|
||||
"""Resolve a tool-result BudgetConfig scaled to the agent's context window.
|
||||
|
||||
|
|
@ -502,10 +524,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe
|
|||
# Checkpoint for file-mutating tools
|
||||
if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}")
|
||||
_ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -1188,12 +1212,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
|||
# Checkpoint: snapshot working dir before file-mutating tools
|
||||
if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled:
|
||||
try:
|
||||
file_path = function_args.get("path", "")
|
||||
if file_path:
|
||||
work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path)
|
||||
agent._checkpoint_mgr.ensure_checkpoint(
|
||||
work_dir, f"before {function_name}"
|
||||
)
|
||||
_ensure_file_checkpoint(
|
||||
agent,
|
||||
function_name,
|
||||
function_args,
|
||||
effective_task_id,
|
||||
)
|
||||
except Exception:
|
||||
pass # never block tool execution
|
||||
|
||||
|
|
|
|||
|
|
@ -125,9 +125,11 @@ normalization alike. Learn the shape, not a snapshot of the current rungs.
|
|||
Two auth-flavored corollaries worth naming because they are easy to get wrong:
|
||||
|
||||
- **One-time credentials are never reused.** An OAuth gateway connection mints a
|
||||
fresh WebSocket ticket on every dial; a mint failure means reauthentication,
|
||||
not "fall back to the cached URL." Only long-lived token/local auth may reuse
|
||||
a cached URL as a lower rung.
|
||||
fresh WebSocket ticket on every dial and never falls back to the cached URL.
|
||||
Only a confirmed 401/403 (or an explicitly tagged auth rejection) means
|
||||
reauthentication; timeout, network, malformed-response, and server failures
|
||||
remain connectivity errors. Only long-lived token/local auth may reuse a
|
||||
cached URL as a lower rung.
|
||||
- **A connection test must exercise the leg you'll actually use.** An HTTP
|
||||
status probe passing while the WebSocket/auth leg fails is a false positive
|
||||
that ships as "it said connected but nothing works."
|
||||
|
|
|
|||
47
apps/desktop/e2e/boot-failure.spec.ts
Normal file
47
apps/desktop/e2e/boot-failure.spec.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't start.
|
||||
*
|
||||
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||
* resolution fails with a controlled error message. The app should show the
|
||||
* BootFailureOverlay with retry/repair actions.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type DeadBackendFixture,
|
||||
setupDeadBackend,
|
||||
waitForBootFailure,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: DeadBackendFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
// BootFailureOverlay in dev mode.
|
||||
fixture = await setupDeadBackend({ fakeError: true })
|
||||
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('screenshot of error state', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
|
||||
})
|
||||
})
|
||||
63
apps/desktop/e2e/boot.spec.ts
Normal file
63
apps/desktop/e2e/boot.spec.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* E2E smoke tests for the dev-mode desktop app.
|
||||
*
|
||||
* These tests launch the Electron app from the built dist/ (not the
|
||||
* packaged binary) with a real `hermes serve` backend pointed at a mock
|
||||
* inference server. The full chain is exercised:
|
||||
*
|
||||
* electron → hermes serve (python) → mock provider → renderer
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
* Run from the nix devshell:
|
||||
* npm exec playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('dev-mode boot with mock backend', () => {
|
||||
test('window opens with Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer mounts and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
// Wait for the React root to mount. The app renders into #root
|
||||
// (see src/main.tsx), but content may arrive through portals — so
|
||||
// check the body for any interactive content instead.
|
||||
await page.waitForSelector('body', { state: 'attached' })
|
||||
// Wait for the main app shell — the composer is always present.
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('backend boots and app becomes ready', async () => {
|
||||
// This is the big one — wait for the full boot chain to complete:
|
||||
// electron starts → hermes serve is spawned → WS connects → config
|
||||
// loaded → sessions loaded → boot overlay dismissed → composer visible.
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test('screenshot after boot', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
91
apps/desktop/e2e/chat.spec.ts
Normal file
91
apps/desktop/e2e/chat.spec.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* E2E chat tests — send a message and verify a response appears.
|
||||
*
|
||||
* Requires the full boot chain to complete (hermes serve + mock inference
|
||||
* provider). The mock server returns a canned reply, so we verify the
|
||||
* response text shows up in the chat transcript.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('chat interaction with mock backend', () => {
|
||||
test('send a message and receive a response', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Find the composer — it's a contenteditable textbox.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// Click to focus, then type the message character by character.
|
||||
// Using `type` instead of `fill` because the composer is a
|
||||
// contenteditable div with custom keydown handling that tracks
|
||||
// IME composition state — `fill` bypasses the event chain.
|
||||
await composer.click()
|
||||
await composer.type('Hello, can you hear me?', { delay: 20 })
|
||||
|
||||
// Submit with Enter — the composer's keydown handler intercepts
|
||||
// plain Enter (without Shift) and calls submitDraft().
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear in the transcript.
|
||||
// The message renders as an assistant-ui message in the chat view.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the mock response to appear. The canned reply is:
|
||||
// "Hello from the mock inference server! The full boot chain is working."
|
||||
// Give it a generous timeout — the inference request goes through the
|
||||
// gateway → hermes serve → mock server → streaming SSE back.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = body.textContent ?? ''
|
||||
|
||||
return text.includes('mock inference server') || text.includes('boot chain is working')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
72
apps/desktop/e2e/fix-electron-tracing.ts
Normal file
72
apps/desktop/e2e/fix-electron-tracing.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Monkey-patch: playwright's test runner never calls tracing.start() on
|
||||
* Electron's internal BrowserContext because:
|
||||
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
|
||||
* contexts — Electron's context is excluded.
|
||||
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
|
||||
* beforeAll launches the electron app.
|
||||
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
|
||||
* class (only on BrowserType).
|
||||
*
|
||||
* As a result, trace screenshots (screencast) and DOM snapshots are never
|
||||
* captured for electron tests.
|
||||
*
|
||||
* This patch:
|
||||
* 1. Patches _allContexts() to include electron contexts, so the test
|
||||
* runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
|
||||
* on the electron context (saving the trace chunk + merging it into
|
||||
* the final trace.zip).
|
||||
* 2. Manually calls tracing.start() + startChunk() after launch.
|
||||
* 3. Wraps tracing.start to become startChunk after the first call,
|
||||
* so the test runner's willStartTest doesn't throw "already started".
|
||||
*
|
||||
* Imported from playwright.config.ts so it runs before any test.
|
||||
*
|
||||
* Pinned dependency: this file reaches into Playwright internals (_playwright,
|
||||
* _allContexts, _context) that have no public contract. @playwright/test is
|
||||
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
|
||||
* monkeypatch. When bumping, re-verify these private symbols still exist on
|
||||
* the Electron / PlaywrightInternal classes and that tracing still merges.
|
||||
*/
|
||||
|
||||
import { _electron as electron, type BrowserContext } from '@playwright/test'
|
||||
import * as crypto from 'node:crypto'
|
||||
|
||||
const electronContexts = new Set<BrowserContext>()
|
||||
const originalLaunch = electron.launch.bind(electron)
|
||||
|
||||
electron.launch = async (options: any) => {
|
||||
const app = await originalLaunch(options)
|
||||
const ctx = (app as any)._context as BrowserContext
|
||||
electronContexts.add(ctx)
|
||||
ctx.once('close', () => electronContexts.delete(ctx))
|
||||
|
||||
// Patch _allContexts so the test runner sees the electron context
|
||||
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
|
||||
const pw = (electron as any)._playwright as any
|
||||
if (pw && !pw.__electronTracingPatched) {
|
||||
pw.__electronTracingPatched = true
|
||||
const original = pw._allContexts.bind(pw)
|
||||
pw._allContexts = () => [...original(), ...electronContexts]
|
||||
}
|
||||
|
||||
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
|
||||
const traceName = crypto.randomUUID()
|
||||
await ctx.tracing.start({
|
||||
screenshots: true,
|
||||
snapshots: true,
|
||||
sources: true,
|
||||
}).catch(() => {})
|
||||
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
|
||||
|
||||
// Wrap tracing.start to redirect to startChunk after the first call.
|
||||
// The test runner's willStartTest calls tracing.start() on all contexts
|
||||
// in _allContexts(). Since we already started, redirect to startChunk
|
||||
// to avoid "Tracing has been already started" errors.
|
||||
const tracing = ctx.tracing as any
|
||||
tracing.start = async (opts: any) => {
|
||||
return tracing.startChunk(opts)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
674
apps/desktop/e2e/fixtures.ts
Normal file
674
apps/desktop/e2e/fixtures.ts
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
/**
|
||||
* Shared E2E fixtures for the Hermes desktop Playwright suite.
|
||||
*
|
||||
* Two fixture modes:
|
||||
*
|
||||
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
|
||||
* that points at it, and launches the desktop app so the full chain
|
||||
* (electron → hermes serve → provider → inference → renderer) is
|
||||
* exercised with a real backend but a fake LLM.
|
||||
*
|
||||
* 2. `noProvider` — launches the app with an empty config (no provider
|
||||
* configured). The onboarding overlay should appear. Used to test the
|
||||
* first-run flow without real credentials.
|
||||
*
|
||||
* Both modes launch the *dev* Electron app (`electron .` against the built
|
||||
* `dist/`), not the packaged binary. This avoids the multi-minute
|
||||
* `electron-builder --dir` step and matches `hermes desktop --source`. The
|
||||
* packaged-binary path is already covered by `launch.spec.ts`.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so that `dist/` exists.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer } from './mock-server'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
|
||||
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
||||
|
||||
const CREDENTIAL_SUFFIXES: string[] = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN',
|
||||
]
|
||||
|
||||
const CREDENTIAL_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL',
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name: string): boolean {
|
||||
if (CREDENTIAL_NAMES.has(name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const clean: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isCredentialEnvVar(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
clean[key] = value
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
// ─── Sandbox creation ──────────────────────────────────────────────────
|
||||
|
||||
export interface Sandbox {
|
||||
root: string
|
||||
hermesHome: string
|
||||
userDataDir: string
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function createSandbox(prefix: string): Sandbox {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
|
||||
// Write a fixed window-state.json so the Electron window opens at a
|
||||
// consistent size — helps with visual regression screenshots. The
|
||||
// exact size is also enforced right before each screenshot (see
|
||||
// expectVisualSnapshot in visual-snapshot.ts) because window managers
|
||||
// may resize after launch.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'window-state.json'),
|
||||
JSON.stringify(
|
||||
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
hermesHome,
|
||||
userDataDir,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config writing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Write a config.yaml that pre-configures a mock provider pointing at the
|
||||
* mock inference server. The provider is set as the active model provider so
|
||||
* the desktop app skips onboarding and boots straight to the chat UI.
|
||||
*/
|
||||
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
||||
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
||||
*/
|
||||
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
const envPath = path.join(hermesHome, '.env')
|
||||
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an empty config (no providers). The desktop app should show the
|
||||
* onboarding overlay because no inference provider is configured.
|
||||
*/
|
||||
function writeEmptyConfig(hermesHome: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
|
||||
}
|
||||
|
||||
// ─── Env building ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the environment for the Electron app process.
|
||||
*
|
||||
* Key env vars:
|
||||
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
|
||||
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
|
||||
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
|
||||
* (we want the dev checkout at REPO_ROOT)
|
||||
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
|
||||
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
||||
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
||||
*/
|
||||
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const clean = stripCredentials(process.env)
|
||||
|
||||
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
||||
// headless/CI context — without it the zygote may fail to initialize.
|
||||
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
|
||||
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
|
||||
}
|
||||
|
||||
// DISPLAY — needed for Electron to open a window.
|
||||
if (!clean.DISPLAY && process.env.DISPLAY) {
|
||||
clean.DISPLAY = process.env.DISPLAY
|
||||
}
|
||||
|
||||
return {
|
||||
...clean,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
||||
// Clear dev-server override — we want the built dist/, not a vite server.
|
||||
// The dev-server check in main.ts looks for this env var; if it's set,
|
||||
// it loads from the vite URL instead of the local file.
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that the desktop app has been built (dist/ exists). Playwright
|
||||
* tests can't run without it — the Electron main process loads
|
||||
* dist/electron-main.mjs and the renderer loads dist/index.html.
|
||||
*/
|
||||
function assertDistBuilt(): void {
|
||||
const distDir = path.join(DESKTOP_ROOT, 'dist')
|
||||
const electronMain = path.join(distDir, 'electron-main.mjs')
|
||||
const indexHtml = path.join(distDir, 'index.html')
|
||||
|
||||
if (!fs.existsSync(electronMain)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${indexHtml}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
||||
* As a fallback, use the node_modules/.bin/electron from the desktop package.
|
||||
*/
|
||||
function findElectron(): string {
|
||||
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
||||
// The dev:electron script in package.json does exactly this: `electron .`
|
||||
// after building. We replicate that here.
|
||||
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
|
||||
if (fs.existsSync(localElectron)) {
|
||||
return localElectron
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
const result = spawnSync('which', ['electron'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the desktop app in dev mode.
|
||||
*
|
||||
* @param sandbox - isolated HERMES_HOME + userData
|
||||
* @param env - the process environment (already has HERMES_HOME etc.)
|
||||
* @returns the ElectronApplication + first Page
|
||||
*/
|
||||
async function launchDesktop(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
assertDistBuilt()
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
// `electron .` loads from the package.json `main` field
|
||||
// (dist/electron-main.mjs after build).
|
||||
const app = await _electron.launch({
|
||||
executablePath: electronBin,
|
||||
args: [
|
||||
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
],
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
// ─── Public fixtures ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a full mock-backend E2E environment:
|
||||
* 1. Start the mock inference server
|
||||
* 2. Create a sandbox with config.yaml pointing at it
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoProviderFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with no provider configured. The onboarding overlay should
|
||||
* appear because there's no inference provider in config.yaml.
|
||||
*/
|
||||
export async function setupNoProvider(): Promise<NoProviderFixture> {
|
||||
const sandbox = createSandbox('noprovider')
|
||||
writeEmptyConfig(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeadBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface DeadBackendOptions {
|
||||
/**
|
||||
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
||||
* so the backend resolution itself "fails" with a controlled error message.
|
||||
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
||||
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
||||
*/
|
||||
fakeError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||
* starts fine — the dead endpoint only matters at chat time). Pass
|
||||
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
||||
* BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
|
||||
const sandbox = createSandbox('dead')
|
||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`# Auto-generated by E2E test fixtures — dead provider
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: http://127.0.0.1:1/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packaged-binary fixture ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the packaged Electron binary path, per-platform, matching
|
||||
* electron-builder's output layout under release/.
|
||||
*/
|
||||
function resolvePackagedBinaryPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
|
||||
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
||||
}
|
||||
|
||||
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
||||
}
|
||||
|
||||
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
||||
|
||||
export function packagedBinaryExists(): boolean {
|
||||
return fs.existsSync(PACKAGED_BINARY_PATH)
|
||||
}
|
||||
|
||||
export interface PackagedAppFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the *packaged* Electron binary (from `npm run pack` →
|
||||
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
|
||||
* progress without spawning a real Hermes backend.
|
||||
*
|
||||
* Uses the same sandbox isolation (credential stripping, isolated
|
||||
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
||||
if (!packagedBinaryExists()) {
|
||||
throw new Error(
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = createSandbox('packaged')
|
||||
|
||||
// Build the sandbox env using the shared helpers, then add the
|
||||
// packaged-binary-specific overrides.
|
||||
const env = buildAppEnv(sandbox, {
|
||||
// Fake boot: simulates progress steps without spawning the real backend.
|
||||
HERMES_DESKTOP_BOOT_FAKE: '1',
|
||||
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
|
||||
})
|
||||
|
||||
// Clear dev-server + hermes-root overrides — the packaged binary
|
||||
// should use its own bundled renderer, not the dev checkout.
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const app = await _electron.launch({
|
||||
executablePath: PACKAGED_BINARY_PATH,
|
||||
args: ['--disable-gpu', '--no-sandbox'],
|
||||
env,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wait helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the desktop app to finish booting and show the main chat UI.
|
||||
*
|
||||
* The boot overlay disappears when `completeDesktopBoot()` fires in the
|
||||
* renderer — at that point the gateway is open, config is loaded, and
|
||||
* sessions are loaded. We detect this by waiting for the boot/connecting
|
||||
* overlay to become invisible and the main app shell to be present.
|
||||
*
|
||||
* Two things must both be true before we return:
|
||||
* 1. The composer (chat input) is visible — it's disabled until the
|
||||
* gateway is open.
|
||||
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
|
||||
* boot-failure) covers the viewport center. The composer can be
|
||||
* "visible" in Playwright's eyes (non-zero bounding box, not
|
||||
* display:none) even when a z-1300+ overlay is painted on top of it,
|
||||
* so checking the composer alone catches the app mid-boot at ~92%
|
||||
* with the loading bar still showing.
|
||||
*/
|
||||
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
|
||||
const { page, app } = fixture
|
||||
|
||||
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
|
||||
// Now poll until no full-screen overlay covers the viewport center.
|
||||
// elementFromPoint returns the topmost element at a point — if it's part
|
||||
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
|
||||
// app isn't ready yet.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk up to the nearest positioned ancestor — overlays are
|
||||
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
||||
// is a full-viewport fixed overlay, we're still covered.
|
||||
let node: Element | null = el
|
||||
while (node) {
|
||||
const cs = window.getComputedStyle(node)
|
||||
|
||||
if (cs.position === 'fixed') {
|
||||
const rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
|
||||
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
|
||||
// and the window stays hidden even though the DOM is rendered. The main
|
||||
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
|
||||
// window, but the DOM can be ready before that fires. Poll until the
|
||||
// window is actually visible so interactions (click, screenshot) don't
|
||||
// hit a hidden surface.
|
||||
if (app) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const visible = await app.evaluate(({ BrowserWindow }) => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
return w ? w.isVisible() : false
|
||||
}).catch(() => false)
|
||||
|
||||
if (visible) {break}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the onboarding overlay to appear (no provider configured).
|
||||
*/
|
||||
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
// The onboarding overlay contains a heading with "Choose your provider"
|
||||
// or similar text. We look for any text that indicates the picker.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
return (
|
||||
text.includes('provider') ||
|
||||
text.includes('Provider') ||
|
||||
text.includes('Choose') ||
|
||||
text.includes('API key') ||
|
||||
text.includes('Sign in')
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the boot failure overlay to appear.
|
||||
*/
|
||||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Boot failure is terminal: the backend gave up. The renderer shows
|
||||
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
||||
// falls back to the onboarding picker (z-1300) as a recovery path.
|
||||
// We wait for the failure dialog itself — the Preparing component may
|
||||
// still paint its progress bar (recolored red) underneath the overlay,
|
||||
// which is harmless.
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
// BootFailureOverlay buttons.
|
||||
const hasFailureUI =
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair') ||
|
||||
text.includes('Use local gateway') ||
|
||||
text.includes('Connection settings')
|
||||
|
||||
// The error toast / notification that fires on failDesktopBoot().
|
||||
const hasErrorToast = text.includes('Desktop boot failed')
|
||||
|
||||
return hasFailureUI || hasErrorToast
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
88
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal file
88
apps/desktop/e2e/launch-packaged-app.spec.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
type PackagedAppFixture,
|
||||
packagedBinaryExists,
|
||||
setupPackagedApp,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
/**
|
||||
* E2E smoke tests for the packaged Hermes desktop app.
|
||||
*
|
||||
* Launches the real packaged Electron binary (produced by `npm run pack` →
|
||||
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
|
||||
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
|
||||
let fixture: PackagedAppFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(
|
||||
!packagedBinaryExists(),
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
|
||||
fixture = await setupPackagedApp()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window opens with the Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer loads and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
const childCount = await page.locator('#root > *').count()
|
||||
expect(childCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('boot progress overlay fades out or shows error state', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
// Error path: boot failure overlay renders an error message.
|
||||
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Success path: overlay disappears and the app renders. If there's
|
||||
// no "boot" / "starting" / "installing" text visible, boot has
|
||||
// completed (either to the main UI or to onboarding).
|
||||
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
return !bootIndicators.some((word) => lower.includes(word))
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('can capture a screenshot for the CI artifact', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Visual snapshot — won't fail on diff, just logs + generates diff image
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
|
||||
})
|
||||
87
apps/desktop/e2e/mock-backend-setup.spec.ts
Normal file
87
apps/desktop/e2e/mock-backend-setup.spec.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
/**
|
||||
* E2E tests asserting the mock backend gets the app past the setup/onboarding
|
||||
* screen.
|
||||
*
|
||||
* The mock backend fixture writes a config.yaml with a pre-configured mock
|
||||
* provider pointing at a mock inference server. When the app boots, the
|
||||
* runtime readiness check should detect the working provider and dismiss the
|
||||
* onboarding overlay — landing straight on the chat UI without ever showing
|
||||
* the "Let's get you setup with Hermes Agent" screen.
|
||||
*
|
||||
* If these tests fail, the mock backend config isn't getting the app past
|
||||
* onboarding — the chat interaction tests (chat.spec.ts) will also fail
|
||||
* because the composer is blocked by the setup overlay.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('mock backend gets past setup screen', () => {
|
||||
test('onboarding overlay is not shown', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
|
||||
// when the runtime check fails to find a working provider. With the mock
|
||||
// backend configured, the runtime check should pass and the overlay
|
||||
// returns null — this text should NOT be present in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
return !text.includes("Let's get you setup")
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('chat composer is visible', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The composer (contenteditable div) should be visible and not blocked
|
||||
// by the onboarding overlay. If the first test passed, the overlay is
|
||||
// gone and the composer is the primary interactive surface.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await expect(composer).toBeVisible()
|
||||
})
|
||||
|
||||
test('can type into the composer', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// If the setup overlay is truly gone, the composer accepts input.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('hello mock backend', { delay: 20 })
|
||||
|
||||
// Verify the typed text appears in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('hello mock backend'),
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot shows chat UI without setup screen', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
203
apps/desktop/e2e/mock-server.ts
Normal file
203
apps/desktop/e2e/mock-server.ts
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Minimal OpenAI-compatible mock inference server for E2E tests.
|
||||
*
|
||||
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
|
||||
* provider, list models, and stream a canned chat completion back to the
|
||||
* desktop app — without any real LLM.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /v1/models → { data: [{ id, ... }] }
|
||||
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
|
||||
*
|
||||
* The canned response is a short, deterministic assistant message. Tool-call
|
||||
* requests are not simulated — the E2E tests only need the chat surface to
|
||||
* prove the full boot → gateway → inference → renderer chain works.
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, and `close()`.
|
||||
*/
|
||||
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers — the Electron renderer doesn't need them, but they
|
||||
// don't hurt and make the server usable from a browser context too.
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// GET /v1/models — return a single fake model.
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'mock-model',
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'mock',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /v1/chat/completions — return a canned response.
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString()
|
||||
})
|
||||
|
||||
req.on('end', () => {
|
||||
let parsed: any = {}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
// malformed JSON — treat as non-streaming with defaults
|
||||
}
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
// Send the content in a few chunks to simulate streaming.
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
// Final chunk with finish_reason
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: word },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
i++
|
||||
// Small delay between chunks to simulate real streaming.
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
} else {
|
||||
// Non-streaming response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => {
|
||||
res.writeHead(400)
|
||||
res.end('Bad request')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback — 404 for anything else
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
|
||||
const port = addr.port
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
rejectClose(err)
|
||||
} else {
|
||||
resolveClose()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
76
apps/desktop/e2e/onboarding.spec.ts
Normal file
76
apps/desktop/e2e/onboarding.spec.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* E2E onboarding tests — verify the provider picker appears when no
|
||||
* inference provider is configured.
|
||||
*
|
||||
* Launches the app with an empty config.yaml (no providers). The renderer
|
||||
* should detect the unconfigured state and show the DesktopOnboardingOverlay
|
||||
* with provider options / API key form.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type NoProviderFixture,
|
||||
setupNoProvider,
|
||||
waitForOnboarding,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: NoProviderFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('onboarding with no provider configured', () => {
|
||||
test('onboarding overlay appears on first boot', async () => {
|
||||
fixture = await setupNoProvider()
|
||||
|
||||
// The app should boot (hermes serve starts fine even without a provider),
|
||||
// but the renderer should show the onboarding overlay because no
|
||||
// provider is configured.
|
||||
await waitForOnboarding(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('onboarding shows provider options or API key form', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const page = fixture.page
|
||||
|
||||
// The onboarding overlay should contain provider-related text.
|
||||
// It might show OAuth providers, an API key form, or a "choose later"
|
||||
// link. Verify at least one of these is visible.
|
||||
const rootText = await page.evaluate(() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
return root?.textContent ?? ''
|
||||
})
|
||||
|
||||
const hasProviderText =
|
||||
rootText.includes('provider') ||
|
||||
rootText.includes('Provider') ||
|
||||
rootText.includes('API key') ||
|
||||
rootText.includes('Sign in') ||
|
||||
rootText.includes('OpenRouter') ||
|
||||
rootText.includes('OpenAI')
|
||||
|
||||
expect(hasProviderText).toBe(true)
|
||||
})
|
||||
|
||||
test('screenshot of onboarding overlay', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
|
||||
})
|
||||
})
|
||||
150
apps/desktop/e2e/visual-snapshot.ts
Normal file
150
apps/desktop/e2e/visual-snapshot.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
/**
|
||||
* Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
|
||||
* reported without failing the test suite.
|
||||
*
|
||||
* On CI, the JSON reporter + post-test script parse the results and post a
|
||||
* summary to the GitHub Actions step output, and diff images are uploaded
|
||||
* as artifacts. This keeps visual regressions visible without gating PRs
|
||||
* on pixel-perfect matches.
|
||||
*
|
||||
* The actual screenshot is always written to the test output dir so CI
|
||||
* artifacts include every screenshot — not just the ones that diffed.
|
||||
* When it differs, this helper also writes expected and diff images:
|
||||
* <name>-actual.png, <name>-expected.png, <name>-diff.png
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { type ElectronApplication, type Page, test } from '@playwright/test'
|
||||
|
||||
/** Fixed window dimensions for visual regression screenshots. */
|
||||
export const VISUAL_WINDOW_WIDTH = 1220
|
||||
export const VISUAL_WINDOW_HEIGHT = 800
|
||||
|
||||
export interface VisualSnapshotOptions {
|
||||
/** Snapshot name — defaults to the test title. */
|
||||
name?: string
|
||||
/** Full page screenshot vs. viewport-only (default). */
|
||||
fullPage?: boolean
|
||||
/** Timeout in ms. */
|
||||
timeout?: number
|
||||
/** The Electron app handle — used to size and decode screenshots. */
|
||||
app: ElectronApplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the Electron window to a fixed size so screenshots are comparable
|
||||
* across runs and CI environments. Window managers (Hyprland, etc.) may
|
||||
* auto-tile or resize windows after launch; calling this right before the
|
||||
* screenshot ensures the viewport is always the expected size.
|
||||
*/
|
||||
async function forceFixedSize(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow }, { width, height }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
if (win) {
|
||||
win.unmaximize()
|
||||
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
|
||||
win.setMinimumSize(width, height)
|
||||
win.setSize(width, height, false)
|
||||
win.setBounds({ x: 0, y: 0, width, height })
|
||||
}
|
||||
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare it against the baseline.
|
||||
*
|
||||
* If the baseline doesn't exist yet (first run), Playwright creates it.
|
||||
* If it differs, the test logs a soft warning but does NOT fail — the diff
|
||||
* images are still generated for CI to surface.
|
||||
*/
|
||||
export async function expectVisualSnapshot(
|
||||
page: Page,
|
||||
options: VisualSnapshotOptions,
|
||||
): Promise<void> {
|
||||
const { name, fullPage = false, timeout = 30_000, app } = options
|
||||
|
||||
// Force the window to a fixed size right before the screenshot so it's
|
||||
// always comparable, regardless of WM resizing during the test.
|
||||
await forceFixedSize(app)
|
||||
// Give the renderer a moment to relayout after the resize.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Playwright appends a platform suffix (e.g. "-linux") and requires
|
||||
// a .png extension on the name argument. Auto-append it if missing.
|
||||
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
|
||||
|
||||
const info = test.info()
|
||||
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
|
||||
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
|
||||
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
|
||||
|
||||
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
|
||||
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
|
||||
fs.writeFileSync(baselinePath, actual)
|
||||
// Also write to the output dir so CI artifacts include the screenshot.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-baseline] updated ${baselinePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
|
||||
return
|
||||
}
|
||||
|
||||
const expected = fs.readFileSync(baselinePath)
|
||||
const comparison = await app.evaluate(
|
||||
({ nativeImage }, images) => {
|
||||
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
|
||||
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
|
||||
const actualSize = actualImage.getSize()
|
||||
const expectedSize = expectedImage.getSize()
|
||||
|
||||
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
|
||||
return { mismatchRatio: 1, diff: images.actual }
|
||||
}
|
||||
|
||||
const actualPixels = actualImage.toBitmap()
|
||||
const expectedPixels = expectedImage.toBitmap()
|
||||
const diffPixels = Buffer.alloc(actualPixels.length)
|
||||
let mismatched = 0
|
||||
|
||||
for (let i = 0; i < actualPixels.length; i += 4) {
|
||||
const different =
|
||||
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
|
||||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
|
||||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
|
||||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
|
||||
|
||||
if (different) {
|
||||
mismatched++
|
||||
diffPixels[i + 2] = 255
|
||||
}
|
||||
diffPixels[i + 3] = 255
|
||||
}
|
||||
|
||||
return {
|
||||
mismatchRatio: mismatched / (actualPixels.length / 4),
|
||||
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
|
||||
}
|
||||
},
|
||||
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
|
||||
)
|
||||
|
||||
// Always write the actual screenshot to the output dir so CI artifacts
|
||||
// include every screenshot — not just the ones that diffed.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
|
||||
if (comparison.mismatchRatio <= 0.01) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
|
||||
console.log(
|
||||
`[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
|
||||
)
|
||||
}
|
||||
|
|
@ -23,6 +23,9 @@ import {
|
|||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
gatewayTicketFailure,
|
||||
gatewayWsUrlIpcResult,
|
||||
isGatewayAuthRejection,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
|
|
@ -491,12 +494,14 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => {
|
|||
assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9')
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => {
|
||||
test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => {
|
||||
const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 })
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw new Error('401 ticket mint failed')
|
||||
throw cause
|
||||
}
|
||||
}),
|
||||
(err: any) => {
|
||||
|
|
@ -512,6 +517,66 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio
|
|||
)
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => {
|
||||
const cause = new Error('socket timed out')
|
||||
|
||||
await assert.rejects(
|
||||
() =>
|
||||
resolveTestWsUrl('https://gw.example.com', 'oauth', null, {
|
||||
mintTicket: async () => {
|
||||
throw cause
|
||||
}
|
||||
}),
|
||||
(err: any) => {
|
||||
assert.match(err.message, /could not mint a WebSocket ticket/i)
|
||||
assert.equal(err.needsOauthLogin, undefined)
|
||||
assert.equal(err.cause, cause)
|
||||
|
||||
return true
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => {
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true)
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true)
|
||||
assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true)
|
||||
assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false)
|
||||
assert.equal(isGatewayAuthRejection(new Error('network timeout')), false)
|
||||
|
||||
const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any
|
||||
assert.equal(serverFailure.message, 'retry connection')
|
||||
assert.equal(serverFailure.needsOauthLogin, undefined)
|
||||
})
|
||||
|
||||
test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => {
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), {
|
||||
ok: true,
|
||||
wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh'
|
||||
})
|
||||
|
||||
for (const statusCode of [401, 403]) {
|
||||
const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode })
|
||||
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
|
||||
error: `${statusCode}: rejected`,
|
||||
needsOauthLogin: true,
|
||||
ok: false
|
||||
})
|
||||
}
|
||||
|
||||
for (const error of [
|
||||
Object.assign(new Error('500: unavailable'), { statusCode: 500 }),
|
||||
new Error('Timed out connecting to Hermes backend after 8000ms'),
|
||||
Object.assign(new Error('socket reset'), { code: 'ECONNRESET' })
|
||||
]) {
|
||||
assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), {
|
||||
error: error.message,
|
||||
ok: false
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => {
|
||||
await assert.rejects(
|
||||
() => resolveTestWsUrl('https://gw.example.com', 'oauth', null),
|
||||
|
|
|
|||
|
|
@ -88,6 +88,43 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}`
|
||||
}
|
||||
|
||||
/** True only when a gateway explicitly rejected the current OAuth session. */
|
||||
function isGatewayAuthRejection(error) {
|
||||
if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) {
|
||||
return true
|
||||
}
|
||||
|
||||
const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN)
|
||||
|
||||
return statusCode === 401 || statusCode === 403
|
||||
}
|
||||
|
||||
function gatewayTicketFailure(error, authMessage, transportMessage) {
|
||||
const needsOauthLogin = isGatewayAuthRejection(error)
|
||||
const err = new Error(needsOauthLogin ? authMessage : transportMessage)
|
||||
|
||||
if (needsOauthLogin) {
|
||||
;(err as any).needsOauthLogin = true
|
||||
}
|
||||
|
||||
err.cause = error
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */
|
||||
async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise<string>) {
|
||||
try {
|
||||
return { ok: true as const, wsUrl: await resolveWsUrl() }
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}),
|
||||
ok: false as const
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the WS URL the renderer would connect with, so the connection test can
|
||||
* exercise the same transport the app actually uses.
|
||||
|
|
@ -102,12 +139,10 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) {
|
|||
* - oauth, mint ok → ws(s)://…/api/ws?ticket=…
|
||||
* - oauth, mint fails → THROWS (NOT a skip)
|
||||
*
|
||||
* The oauth-mint-failure throw is the important case: the real boot path
|
||||
* (resolveRemoteBackend in main.ts) treats a mint failure as a hard
|
||||
* "session expired" auth error and refuses to connect. Swallowing it here
|
||||
* would re-introduce the exact false-positive this test exists to catch —
|
||||
* HTTP /api/status passes, the test reports "reachable", then the renderer
|
||||
* can't authenticate /api/ws and boot dies with "Could not connect".
|
||||
* The oauth-mint-failure throw is the important case: swallowing it here would
|
||||
* re-introduce the exact false-positive this test exists to catch. An explicit
|
||||
* 401/403 asks for sign-in; transport and server failures remain connectivity
|
||||
* errors so a temporary outage is not mislabeled as an expired session.
|
||||
*
|
||||
* @param {string} baseUrl
|
||||
* @param {'token'|'oauth'} authMode
|
||||
|
|
@ -128,14 +163,12 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) {
|
|||
try {
|
||||
ticket = await mintTicket(baseUrl)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' +
|
||||
'(it may have expired). Open Settings → Gateway and sign in again.'
|
||||
throw gatewayTicketFailure(
|
||||
error,
|
||||
'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' +
|
||||
'Open Settings → Gateway and sign in again.',
|
||||
'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.'
|
||||
)
|
||||
|
||||
;(err as any).needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
|
||||
return buildGatewayWsUrlWithTicket(baseUrl, ticket)
|
||||
|
|
@ -458,7 +491,10 @@ export {
|
|||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
gatewayTicketFailure,
|
||||
gatewayWsUrlIpcResult,
|
||||
hostLabelFromBaseUrl,
|
||||
isGatewayAuthRejection,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
normalizeRemoteBaseUrl,
|
||||
|
|
|
|||
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
37
apps/desktop/electron/event-dedupe.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
|
||||
test('collapses the same key inside the window (two windows, one event)', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false, 'first window claims')
|
||||
assert.equal(isDup('input:s1', 5), true, 'second window is deduped')
|
||||
})
|
||||
|
||||
test('distinct keys are independent', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('input:s1', 0), false)
|
||||
assert.equal(isDup('approval:s1', 0), false, 'different kind')
|
||||
assert.equal(isDup('input:s2', 0), false, 'different session')
|
||||
})
|
||||
|
||||
test('re-fires once the window elapses', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
assert.equal(isDup('turnDone:s1', 0), false)
|
||||
assert.equal(isDup('turnDone:s1', 999), true, 'still within window')
|
||||
assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again')
|
||||
})
|
||||
|
||||
test('prunes stale keys so the map cannot grow unbounded', () => {
|
||||
const isDup = createEventDeduper(1000)
|
||||
|
||||
for (let i = 0; i < 100; i += 1) {
|
||||
// Each far-apart key is pruned before the next, so none linger as duplicates.
|
||||
assert.equal(isDup(`turnDone:s${i}`, i * 2000), false)
|
||||
}
|
||||
})
|
||||
32
apps/desktop/electron/event-dedupe.ts
Normal file
32
apps/desktop/electron/event-dedupe.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end
|
||||
// sound, spoken replies). Every desktop window is its own renderer process, so N
|
||||
// open windows each independently react to the same backend event. The main
|
||||
// process is the one place they all share and it handles IPC serially, so it's
|
||||
// the race-free owner: the first window to claim a key within the interval wins;
|
||||
// peers see it's taken and stay quiet. Pure + injectable clock, so it's
|
||||
// unit-testable without Electron.
|
||||
|
||||
const DEDUPE_INTERVAL_MS = 1000
|
||||
|
||||
// Returns true when `key` was already claimed within the interval (caller drops
|
||||
// this one). Self-evicting: stale keys are pruned on every call, so the map
|
||||
// can't grow unbounded.
|
||||
export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
|
||||
const lastSeenAt = new Map<string, number>()
|
||||
|
||||
return function isDuplicate(key: string, now = Date.now()): boolean {
|
||||
for (const [k, at] of lastSeenAt) {
|
||||
if (now - at >= intervalMs) {
|
||||
lastSeenAt.delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
if (lastSeenAt.has(key)) {
|
||||
return true
|
||||
}
|
||||
|
||||
lastSeenAt.set(key, now)
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import {
|
|||
nativeTheme,
|
||||
Notification,
|
||||
powerMonitor,
|
||||
powerSaveBlocker,
|
||||
protocol,
|
||||
safeStorage,
|
||||
screen,
|
||||
|
|
@ -47,6 +48,8 @@ import {
|
|||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
cookiesHaveSession,
|
||||
gatewayTicketFailure,
|
||||
gatewayWsUrlIpcResult,
|
||||
hostLabelFromBaseUrl,
|
||||
localProfileEntry,
|
||||
modeIsRemoteLike,
|
||||
|
|
@ -74,6 +77,7 @@ import {
|
|||
uninstallArgsForMode
|
||||
} from './desktop-uninstall'
|
||||
import { installEmbedReferer } from './embed-referer'
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
|
|
@ -113,12 +117,15 @@ import {
|
|||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { ensureMainWindow } from './main-window-lifecycle'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { createKeepAwake } from './power-save'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
import * as remoteLifecycle from './remote-lifecycle'
|
||||
import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
} from './session-windows'
|
||||
|
|
@ -567,6 +574,7 @@ const DESKTOP_LOG_BACKUP_COUNT = 3
|
|||
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
||||
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||||
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
|
||||
|
||||
const BOOT_FAKE_STEP_MS = (() => {
|
||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||
|
|
@ -955,9 +963,8 @@ function registerMediaProtocol() {
|
|||
|
||||
let mainWindow = null
|
||||
const backendConnectionState = createBackendConnectionState<ReturnType<typeof spawn>, any>()
|
||||
// Consecutive indeterminate liveness-probe failures for the cached remote
|
||||
// backend; teardown fires only on proof (refused) or a full streak.
|
||||
let revalidateTimeoutStreak = 0
|
||||
const remoteLiveness = new RemoteLivenessTracker()
|
||||
const remoteRevalidation = new RemoteRevalidationCoordinator()
|
||||
// True while connection-config:apply soft-rehomes the primary — suppresses the
|
||||
// backend-exit toast so an intentional kill doesn't look like a crash.
|
||||
let softRehomeInProgress = false
|
||||
|
|
@ -4656,9 +4663,9 @@ function getNativeOverlayWidth() {
|
|||
return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC })
|
||||
}
|
||||
|
||||
function getWindowState() {
|
||||
function getWindowState(win = mainWindow) {
|
||||
return {
|
||||
isFullscreen: Boolean(mainWindow?.isFullScreen?.()),
|
||||
isFullscreen: Boolean(win?.isFullScreen?.()),
|
||||
nativeOverlayWidth: getNativeOverlayWidth(),
|
||||
windowButtonPosition: getWindowButtonPosition()
|
||||
}
|
||||
|
|
@ -4759,18 +4766,21 @@ function sendOpenUpdatesRequested() {
|
|||
mainWindow.focus()
|
||||
}
|
||||
|
||||
function sendWindowStateChanged(nextIsFullscreen?: boolean) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the
|
||||
// primary, but any full chat window (primary or a secondary "instance" peer)
|
||||
// passes itself so its own fullscreen toggle drives its own traffic-light inset.
|
||||
function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) {
|
||||
if (!target || target.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const { webContents } = mainWindow
|
||||
const { webContents } = target
|
||||
|
||||
if (!webContents || webContents.isDestroyed()) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getWindowState()
|
||||
const state = getWindowState(target)
|
||||
|
||||
if (typeof nextIsFullscreen === 'boolean') {
|
||||
state.isFullscreen = nextIsFullscreen
|
||||
|
|
@ -4808,6 +4818,11 @@ function buildApplicationMenu() {
|
|||
template.push({
|
||||
label: 'File',
|
||||
submenu: [
|
||||
// No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow);
|
||||
// a menu accelerator would fight the rebind panel and (on macOS) be
|
||||
// swallowed before the renderer sees it. Here purely for discoverability.
|
||||
{ click: () => createInstanceWindow(), label: 'New Window' },
|
||||
{ type: 'separator' },
|
||||
IS_MAC
|
||||
? {
|
||||
// NO accelerator: on macOS a registered ⌘W is consumed by the OS
|
||||
|
|
@ -6462,13 +6477,11 @@ async function buildRemoteConnection(
|
|||
try {
|
||||
ticket = await mintGatewayWsTicket(baseUrl)
|
||||
} catch (error) {
|
||||
const err = new Error(
|
||||
'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.'
|
||||
) as any
|
||||
|
||||
err.needsOauthLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
throw gatewayTicketFailure(
|
||||
error,
|
||||
'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.',
|
||||
'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -7151,10 +7164,7 @@ function stopBackendChild(child) {
|
|||
// switch / crash recovery), which still resets boot progress + reloads.
|
||||
function resetHermesConnection({ soft = false } = {}) {
|
||||
backendStartFailure = null
|
||||
// A new connection generation must not inherit an old backend's timeout
|
||||
// streak — one inherited strike would revert its first slow probe to
|
||||
// single-timeout teardown aggression.
|
||||
revalidateTimeoutStreak = 0
|
||||
remoteLiveness.clear()
|
||||
const hermesProcess = backendConnectionState.invalidate()
|
||||
stopBackendChild(hermesProcess)
|
||||
|
||||
|
|
@ -7546,6 +7556,16 @@ async function startHermes() {
|
|||
throw backendStartFailure
|
||||
}
|
||||
|
||||
// E2E: simulate a boot failure without breaking the real backend. The boot
|
||||
// progresses a few steps, then fails with the given error message.
|
||||
if (BOOT_FAKE_ERROR) {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const error = new Error(BOOT_FAKE_ERROR) as any
|
||||
error.isBootstrapFailure = true
|
||||
bootstrapFailure = error
|
||||
throw error
|
||||
}
|
||||
|
||||
const existingConnectionPromise = backendConnectionState.getPromise()
|
||||
|
||||
if (existingConnectionPromise) {
|
||||
|
|
@ -7866,11 +7886,7 @@ function focusWindow(win) {
|
|||
win.focus()
|
||||
}
|
||||
|
||||
function spawnSecondaryWindow({
|
||||
sessionId,
|
||||
watch,
|
||||
newSession
|
||||
}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) {
|
||||
function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) {
|
||||
const icon = getAppIconPath()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
|
|
@ -7915,8 +7931,7 @@ function spawnSecondaryWindow({
|
|||
buildSessionWindowUrl(sessionId, {
|
||||
devServer: DEV_SERVER,
|
||||
rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(),
|
||||
watch,
|
||||
newSession
|
||||
watch
|
||||
})
|
||||
)
|
||||
|
||||
|
|
@ -7928,11 +7943,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) {
|
|||
return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch }))
|
||||
}
|
||||
|
||||
// Open a fresh compact window on the new-session draft (#/). Not registry-keyed:
|
||||
// like ⌘N in a browser, every press opens a new window — and a draft window that
|
||||
// later converts to a real session must not get refocused as if it were blank.
|
||||
function createNewSessionWindow() {
|
||||
return spawnSecondaryWindow({ newSession: true })
|
||||
// Additional full "instance" windows — peers of the primary that render the
|
||||
// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so
|
||||
// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette
|
||||
// command). Unlike the compact session windows they carry no `?win` flag. The
|
||||
// primary mainWindow stays the notification / deep-link / pet-overlay anchor and
|
||||
// is NOT tracked here. The set holds a strong reference so an open peer isn't
|
||||
// garbage-collected, and drops it on close.
|
||||
const instanceWindows = new Set<any>()
|
||||
|
||||
// Cascade a new instance off whichever window spawned it so it doesn't land
|
||||
// exactly on top of its source. Falls back to the persisted primary geometry
|
||||
// when there's no live source window (e.g. all windows closed on macOS). The
|
||||
// pure cascade math lives in session-windows.ts (instanceWindowBounds).
|
||||
function nextInstanceBounds() {
|
||||
const source = BrowserWindow.getFocusedWindow() || mainWindow
|
||||
const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays())
|
||||
const base = source && !source.isDestroyed() ? source.getBounds() : null
|
||||
|
||||
return instanceWindowBounds(base, fallback)
|
||||
}
|
||||
|
||||
// Open a new full-chrome instance window. Mirrors createWindow()'s window
|
||||
// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a
|
||||
// streamed answer never stalls in the background) but is a peer, not the
|
||||
// primary: it never overwrites the mainWindow global, doesn't start the backend
|
||||
// (the renderer's getConnection() joins the already-running one), and loads the
|
||||
// plain renderer URL so the full app renders.
|
||||
function createInstanceWindow() {
|
||||
const icon = getAppIconPath()
|
||||
|
||||
const win = new BrowserWindow({
|
||||
...nextInstanceBounds(),
|
||||
minWidth: WINDOW_MIN_WIDTH,
|
||||
minHeight: WINDOW_MIN_HEIGHT,
|
||||
title: 'Hermes',
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: getTitleBarOverlayOptions(),
|
||||
trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined,
|
||||
vibrancy: IS_MAC ? 'sidebar' : undefined,
|
||||
opacity: windowOpacity(),
|
||||
icon,
|
||||
show: false,
|
||||
backgroundColor: getWindowBackgroundColor(),
|
||||
webPreferences: chatWindowWebPreferences(PRELOAD_PATH)
|
||||
})
|
||||
|
||||
instanceWindows.add(win)
|
||||
|
||||
if (IS_MAC) {
|
||||
win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION)
|
||||
}
|
||||
|
||||
win.once('ready-to-show', () => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.show()
|
||||
}
|
||||
})
|
||||
|
||||
// Per-window fullscreen chrome: send this window its own titlebar inset so its
|
||||
// traffic lights hide/show independently of the primary.
|
||||
win.on('enter-full-screen', () => sendWindowStateChanged(true, win))
|
||||
win.on('leave-full-screen', () => sendWindowStateChanged(false, win))
|
||||
|
||||
wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat'))
|
||||
|
||||
win.on('closed', () => {
|
||||
instanceWindows.delete(win)
|
||||
})
|
||||
|
||||
if (DEV_SERVER) {
|
||||
win.loadURL(DEV_SERVER)
|
||||
} else {
|
||||
win.loadURL(pathToFileURL(resolveRendererIndex()).toString())
|
||||
}
|
||||
|
||||
return win
|
||||
}
|
||||
|
||||
// The pet overlay: a single transparent, frameless, always-on-top window that
|
||||
|
|
@ -8120,7 +8206,9 @@ function createWindow() {
|
|||
if (!nativeThemeListenerInstalled) {
|
||||
nativeThemeListenerInstalled = true
|
||||
nativeTheme.on('updated', () => {
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyTitleBarOverlay(win)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -8158,6 +8246,14 @@ function createWindow() {
|
|||
}
|
||||
})
|
||||
|
||||
// Under Playright testing, instantly show the window.
|
||||
// `ready-to-show` doesn't fire in some testing envs.
|
||||
if (process.env.TEST_WORKER_INDEX !== undefined) {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
|
@ -8308,64 +8404,43 @@ ipcMain.handle('hermes:connection:revalidate', async () => {
|
|||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
let conn = null
|
||||
// Main and every session pop-out have their own renderer reconnect loop but
|
||||
// share this primary connection. Coalesce simultaneous requests so one outage
|
||||
// produces one failure observation rather than exhausting the whole streak.
|
||||
return remoteRevalidation.run(connectionPromise, async () => {
|
||||
const result = await revalidateRemoteConnection({
|
||||
connectionPromise,
|
||||
currentConnectionPromise: () => backendConnectionState.getPromise(),
|
||||
log: rememberLog,
|
||||
probe: fetchPublicJson,
|
||||
resetConnection: resetHermesConnection,
|
||||
tracker: remoteLiveness
|
||||
})
|
||||
|
||||
try {
|
||||
conn = await connectionPromise
|
||||
} catch {
|
||||
// The cached boot already rejected (its own catch clears the promise);
|
||||
// nothing to revalidate — the next getConnection() builds fresh.
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
// A rebuilt SSH connection must also tear down its tunnel/master before the
|
||||
// renderer re-dials (which only happens after this handler resolves), so the
|
||||
// fresh bootstrap can't reattach to a dying transport.
|
||||
if (result.rebuilt) {
|
||||
const conn = await connectionPromise.catch(() => null)
|
||||
|
||||
if (!conn || conn.mode !== 'remote' || !conn.baseUrl) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
const base = conn.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
await fetchPublicJson(`${base}/api/status`, { timeoutMs: 10_000 })
|
||||
revalidateTimeoutStreak = 0
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
} catch (error: any) {
|
||||
// A timeout is indeterminate (slow VM, idle tunnel re-establishing) — not
|
||||
// proof of death. Tear down only on proof (connection refused) or after a
|
||||
// bounded streak of timeouts, so one slow answer can't destroy a healthy
|
||||
// transport and cancel in-flight bootstraps.
|
||||
const refused = /ECONNREFUSED/i.test(String(error?.code || error?.cause?.code || error?.message || ''))
|
||||
|
||||
if (!refused) {
|
||||
revalidateTimeoutStreak += 1
|
||||
|
||||
if (revalidateTimeoutStreak < 3) {
|
||||
rememberLog(`Remote liveness probe indeterminate (${revalidateTimeoutStreak}/3); keeping connection.`)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
if (conn?.remoteKind === 'ssh') {
|
||||
const profile = primaryProfileKey()
|
||||
await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile))
|
||||
await teardownSshConnection(profile)
|
||||
}
|
||||
}
|
||||
|
||||
revalidateTimeoutStreak = 0
|
||||
rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
||||
|
||||
if (conn.remoteKind === 'ssh') {
|
||||
const profile = primaryProfileKey()
|
||||
await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile))
|
||||
await teardownSshConnection(profile)
|
||||
}
|
||||
|
||||
resetHermesConnection()
|
||||
|
||||
return { ok: true, rebuilt: true }
|
||||
}
|
||||
return result
|
||||
})
|
||||
})
|
||||
ipcMain.handle('hermes:backend:touch', async (_event, profile) => {
|
||||
touchPoolBackend(profile)
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile))
|
||||
ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => {
|
||||
return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile))
|
||||
})
|
||||
ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
||||
if (typeof sessionId !== 'string' || !sessionId.trim()) {
|
||||
return { ok: false, error: 'invalid-session-id' }
|
||||
|
|
@ -8375,8 +8450,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => {
|
|||
|
||||
return { ok: true }
|
||||
})
|
||||
ipcMain.handle('hermes:window:openNewSession', async () => {
|
||||
createNewSessionWindow()
|
||||
ipcMain.handle('hermes:window:openInstance', async () => {
|
||||
createInstanceWindow()
|
||||
|
||||
return { ok: true }
|
||||
})
|
||||
|
|
@ -9032,11 +9107,28 @@ ipcMain.handle('hermes:api', async (_event, request) => {
|
|||
})
|
||||
})
|
||||
|
||||
// One deduper per cross-window cue — the choke point every window shares. Main
|
||||
// handles IPC serially, so the first window to claim a key wins with no race.
|
||||
const isDuplicateNotification = createEventDeduper()
|
||||
const claimedAmbientCue = createEventDeduper()
|
||||
|
||||
// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?".
|
||||
// The first caller within the window gets true; peers get false and stay quiet.
|
||||
ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? '')))
|
||||
|
||||
ipcMain.handle('hermes:notify', (_event, payload) => {
|
||||
if (!Notification.isSupported()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Multiple full windows each run their own renderer throttle, so the same
|
||||
// kind+session can arrive here twice. Collapse it at this single choke point.
|
||||
// Return true (not false): a notification for the event IS being shown by the
|
||||
// first caller, so the settings "send test" success probe stays honest.
|
||||
if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Action buttons render only on signed macOS builds; elsewhere they're dropped
|
||||
// and the body click still works.
|
||||
const actions = Array.isArray(payload?.actions) ? payload.actions : []
|
||||
|
|
@ -9206,7 +9298,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
|
|||
background: payload.background,
|
||||
foreground: payload.foreground
|
||||
}
|
||||
applyTitleBarOverlay(mainWindow)
|
||||
|
||||
// Repaint the native (Windows/Linux) titlebar overlay on every open chat
|
||||
// window, not just the primary — instance peers and session windows share the
|
||||
// one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay.
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
applyTitleBarOverlay(win)
|
||||
}
|
||||
})
|
||||
|
||||
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
|
||||
|
|
@ -9238,6 +9336,33 @@ ipcMain.on('hermes:translucency', (_event, payload) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one
|
||||
// blocker and its persisted state so a cold launch restores it (applied on
|
||||
// ready — powerSaveBlocker needs the app ready). The renderer toggles it from
|
||||
// Settings → Advanced over IPC. See store/keep-awake.
|
||||
const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json')
|
||||
const keepAwake = createKeepAwake(powerSaveBlocker)
|
||||
|
||||
function readPersistedKeepAwake() {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.on('hermes:keep-awake', (_event, on) => {
|
||||
const enabled = Boolean(on)
|
||||
keepAwake.set(enabled)
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true })
|
||||
fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8')
|
||||
} catch (error) {
|
||||
rememberLog(`[keep-awake] write failed: ${error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:openExternal', (_event, url) => {
|
||||
if (!openExternalUrl(url)) {
|
||||
throw new Error('Invalid external URL')
|
||||
|
|
@ -10253,6 +10378,7 @@ app.whenReady().then(() => {
|
|||
ensureWslWindowsFonts()
|
||||
configureSpellChecker()
|
||||
registerPowerResumeListeners()
|
||||
keepAwake.set(readPersistedKeepAwake())
|
||||
createWindow()
|
||||
|
||||
// Win/Linux cold start: the launching hermes:// URL is in our own argv.
|
||||
|
|
|
|||
58
apps/desktop/electron/power-save.test.ts
Normal file
58
apps/desktop/electron/power-save.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createKeepAwake, type PowerSaveBlockerLike } from './power-save'
|
||||
|
||||
function fakeBlocker() {
|
||||
let next = 1
|
||||
const started = new Set<number>()
|
||||
|
||||
const blocker: PowerSaveBlockerLike = {
|
||||
isStarted: id => started.has(id),
|
||||
start: vi.fn(() => {
|
||||
const id = next++
|
||||
started.add(id)
|
||||
|
||||
return id
|
||||
}),
|
||||
stop: vi.fn(id => void started.delete(id))
|
||||
}
|
||||
|
||||
return { blocker, started }
|
||||
}
|
||||
|
||||
describe('createKeepAwake', () => {
|
||||
it('starts once, is idempotent, and stops', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
expect(keepAwake.set(true)).toBe(true)
|
||||
keepAwake.set(true) // idempotent — no second blocker
|
||||
expect(blocker.start).toHaveBeenCalledTimes(1)
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension')
|
||||
|
||||
expect(keepAwake.set(false)).toBe(false)
|
||||
keepAwake.set(false)
|
||||
expect(blocker.stop).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('re-arms after the OS dropped the blocker', () => {
|
||||
const { blocker, started } = fakeBlocker()
|
||||
const keepAwake = createKeepAwake(blocker)
|
||||
|
||||
keepAwake.set(true)
|
||||
started.clear() // system released it out from under us
|
||||
expect(keepAwake.isActive()).toBe(false)
|
||||
|
||||
keepAwake.set(true)
|
||||
expect(blocker.start).toHaveBeenCalledTimes(2)
|
||||
expect(keepAwake.isActive()).toBe(true)
|
||||
})
|
||||
|
||||
it('honors a custom blocker type', () => {
|
||||
const { blocker } = fakeBlocker()
|
||||
createKeepAwake(blocker, 'prevent-display-sleep').set(true)
|
||||
|
||||
expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep')
|
||||
})
|
||||
})
|
||||
50
apps/desktop/electron/power-save.ts
Normal file
50
apps/desktop/electron/power-save.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
/**
|
||||
* Keep-awake — hold a single machine-global power-save blocker.
|
||||
*
|
||||
* `prevent-app-suspension` stops the system from sleeping (long overnight
|
||||
* agent runs keep going) while still letting the display dim. The renderer
|
||||
* owns the preference (persisted in localStorage) and mirrors it here over
|
||||
* IPC; the main process owns the one native blocker, same authority split as
|
||||
* translucency/zoom. Electron auto-releases the blocker on quit.
|
||||
*/
|
||||
|
||||
export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep'
|
||||
|
||||
/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */
|
||||
export interface PowerSaveBlockerLike {
|
||||
start(type: KeepAwakeType): number
|
||||
stop(id: number): void
|
||||
isStarted(id: number): boolean
|
||||
}
|
||||
|
||||
export interface KeepAwake {
|
||||
/** Turn the blocker on/off (idempotent). Returns the resulting state. */
|
||||
set(on: boolean): boolean
|
||||
isActive(): boolean
|
||||
}
|
||||
|
||||
export function createKeepAwake(
|
||||
blocker: PowerSaveBlockerLike,
|
||||
type: KeepAwakeType = 'prevent-app-suspension'
|
||||
): KeepAwake {
|
||||
let id: null | number = null
|
||||
|
||||
const isActive = () => id !== null && blocker.isStarted(id)
|
||||
|
||||
return {
|
||||
isActive,
|
||||
set(on) {
|
||||
if (on && !isActive()) {
|
||||
id = blocker.start(type)
|
||||
} else if (!on && id !== null) {
|
||||
if (blocker.isStarted(id)) {
|
||||
blocker.stop(id)
|
||||
}
|
||||
|
||||
id = null
|
||||
}
|
||||
|
||||
return isActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile),
|
||||
getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile),
|
||||
openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts),
|
||||
openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'),
|
||||
openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'),
|
||||
claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key),
|
||||
petOverlay: {
|
||||
// Main renderer → main process: window lifecycle + drag. `request` is
|
||||
// `{ bounds, screen }`; resolves with the screen bounds it actually used.
|
||||
|
|
@ -81,6 +82,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
|||
setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload),
|
||||
setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode),
|
||||
setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload),
|
||||
setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on),
|
||||
setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)),
|
||||
openExternal: url => ipcRenderer.invoke('hermes:openExternal', url),
|
||||
openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url),
|
||||
|
|
|
|||
253
apps/desktop/electron/remote-liveness.test.ts
Normal file
253
apps/desktop/electron/remote-liveness.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
REMOTE_LIVENESS_FAILURE_WINDOW_MS,
|
||||
REMOTE_LIVENESS_TIMEOUT_MS,
|
||||
RemoteLivenessTracker,
|
||||
RemoteRevalidationCoordinator,
|
||||
revalidateRemoteConnection
|
||||
} from './remote-liveness'
|
||||
|
||||
describe('RemoteLivenessTracker', () => {
|
||||
it('requires consecutive failures before resetting a connection', () => {
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
for (let failures = 1; failures < REMOTE_LIVENESS_FAILURE_LIMIT; failures += 1) {
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures, shouldReset: false })
|
||||
}
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({
|
||||
failures: REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
shouldReset: true
|
||||
})
|
||||
})
|
||||
|
||||
it('clears a failure streak after a successful probe', () => {
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
tracker.recordFailure('https://gateway.example.com')
|
||||
tracker.recordFailure('https://gateway.example.com')
|
||||
tracker.recordSuccess('https://gateway.example.com')
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('tracks different gateways independently', () => {
|
||||
const tracker = new RemoteLivenessTracker(2)
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 2, shouldReset: true })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: true })
|
||||
})
|
||||
|
||||
it('clears only the successful gateway streak', () => {
|
||||
const tracker = new RemoteLivenessTracker(3)
|
||||
|
||||
tracker.recordFailure('https://one.example.com')
|
||||
tracker.recordFailure('https://two.example.com')
|
||||
tracker.recordSuccess('https://one.example.com')
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: false })
|
||||
})
|
||||
|
||||
it('does not accumulate isolated failures across separate reconnect episodes', () => {
|
||||
let now = 0
|
||||
const tracker = new RemoteLivenessTracker(3, REMOTE_LIVENESS_FAILURE_WINDOW_MS, () => now)
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
now += REMOTE_LIVENESS_FAILURE_WINDOW_MS + 1
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('clears all failure streaks when the connection state resets', () => {
|
||||
const tracker = new RemoteLivenessTracker(3)
|
||||
|
||||
tracker.recordFailure('https://one.example.com')
|
||||
tracker.recordFailure('https://two.example.com')
|
||||
tracker.clear()
|
||||
|
||||
expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('starts a fresh streak after the reset threshold is consumed', () => {
|
||||
const tracker = new RemoteLivenessTracker(1)
|
||||
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
|
||||
expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true })
|
||||
})
|
||||
|
||||
it('rejects invalid failure limits', () => {
|
||||
expect(() => new RemoteLivenessTracker(0)).toThrow(/positive integer/i)
|
||||
expect(() => new RemoteLivenessTracker(1.5)).toThrow(/positive integer/i)
|
||||
expect(() => new RemoteLivenessTracker(1, 0)).toThrow(/window must be positive/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RemoteRevalidationCoordinator', () => {
|
||||
it('coalesces simultaneous probes for the same cached connection', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
let resolveProbe: (value: string) => void = () => undefined
|
||||
|
||||
const probe = vi.fn(
|
||||
() =>
|
||||
new Promise<string>(resolve => {
|
||||
resolveProbe = resolve
|
||||
})
|
||||
)
|
||||
|
||||
const first = coordinator.run(connection, probe)
|
||||
const second = coordinator.run(connection, probe)
|
||||
const third = coordinator.run(connection, probe)
|
||||
|
||||
await Promise.resolve()
|
||||
|
||||
expect(second).toBe(first)
|
||||
expect(third).toBe(first)
|
||||
expect(probe).toHaveBeenCalledOnce()
|
||||
|
||||
resolveProbe('healthy')
|
||||
await expect(Promise.all([first, second, third])).resolves.toEqual(['healthy', 'healthy', 'healthy'])
|
||||
})
|
||||
|
||||
it('runs a fresh probe after the prior one settles', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
const probe = vi.fn().mockResolvedValue('healthy')
|
||||
|
||||
await coordinator.run(connection, probe)
|
||||
await coordinator.run(connection, probe)
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not coalesce different cached connections', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const probe = vi.fn().mockResolvedValue('healthy')
|
||||
|
||||
await Promise.all([coordinator.run(Promise.resolve('one'), probe), coordinator.run(Promise.resolve('two'), probe)])
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cleans up a rejected probe so it can be retried', async () => {
|
||||
const coordinator = new RemoteRevalidationCoordinator()
|
||||
const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' })
|
||||
const probe = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('healthy')
|
||||
|
||||
await expect(coordinator.run(connection, probe)).rejects.toThrow('offline')
|
||||
await expect(coordinator.run(connection, probe)).resolves.toBe('healthy')
|
||||
expect(probe).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revalidateRemoteConnection', () => {
|
||||
function harness(overrides: Record<string, unknown> = {}) {
|
||||
const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' }
|
||||
const connectionPromise = Promise.resolve(connection)
|
||||
const current = { promise: connectionPromise as null | Promise<typeof connection> }
|
||||
const log = vi.fn()
|
||||
const probe = vi.fn().mockResolvedValue({ ok: true })
|
||||
const resetConnection = vi.fn()
|
||||
const tracker = new RemoteLivenessTracker()
|
||||
|
||||
return {
|
||||
connectionPromise,
|
||||
current,
|
||||
log,
|
||||
options: {
|
||||
connectionPromise,
|
||||
currentConnectionPromise: () => current.promise,
|
||||
log,
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker,
|
||||
...overrides
|
||||
},
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker
|
||||
}
|
||||
}
|
||||
|
||||
it('probes the normalized status URL with the production timeout', async () => {
|
||||
const test = harness()
|
||||
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', {
|
||||
timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS
|
||||
})
|
||||
expect(test.resetConnection).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps failures one and two, then resets on the third failure', async () => {
|
||||
const probe = vi.fn().mockRejectedValue(new Error('offline'))
|
||||
const test = harness({ probe })
|
||||
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: true })
|
||||
|
||||
expect(probe).toHaveBeenCalledTimes(3)
|
||||
expect(test.resetConnection).toHaveBeenCalledOnce()
|
||||
expect(test.log).toHaveBeenNthCalledWith(1, expect.stringContaining('(1/3)'))
|
||||
expect(test.log).toHaveBeenNthCalledWith(2, expect.stringContaining('(2/3)'))
|
||||
expect(test.log).toHaveBeenLastCalledWith(expect.stringContaining('dropping stale connection'))
|
||||
})
|
||||
|
||||
it('ignores a late failed probe after the cached connection is replaced', async () => {
|
||||
let rejectProbe: (error: Error) => void = () => undefined
|
||||
|
||||
const probe = vi.fn(
|
||||
() =>
|
||||
new Promise((_resolve, reject) => {
|
||||
rejectProbe = reject
|
||||
})
|
||||
)
|
||||
|
||||
const test = harness({ probe })
|
||||
const pending = revalidateRemoteConnection(test.options)
|
||||
|
||||
await Promise.resolve()
|
||||
test.current.promise = Promise.resolve({ baseUrl: 'https://new.example.com', mode: 'remote' })
|
||||
rejectProbe(new Error('old connection failed'))
|
||||
|
||||
await expect(pending).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(test.resetConnection).not.toHaveBeenCalled()
|
||||
expect(test.log).not.toHaveBeenCalled()
|
||||
expect(test.tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false })
|
||||
})
|
||||
|
||||
it('does not probe a local, rejected, or already replaced connection', async () => {
|
||||
const replaced = harness()
|
||||
|
||||
replaced.current.promise = null
|
||||
await expect(revalidateRemoteConnection(replaced.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(replaced.probe).not.toHaveBeenCalled()
|
||||
|
||||
const localConnection = { baseUrl: 'http://127.0.0.1:3000', mode: 'local' }
|
||||
const localPromise = Promise.resolve(localConnection)
|
||||
|
||||
const local = harness({
|
||||
connectionPromise: localPromise,
|
||||
currentConnectionPromise: () => localPromise
|
||||
})
|
||||
|
||||
await expect(revalidateRemoteConnection(local.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(local.probe).not.toHaveBeenCalled()
|
||||
|
||||
const rejectedPromise = Promise.reject(new Error('boot failed'))
|
||||
|
||||
const rejected = harness({
|
||||
connectionPromise: rejectedPromise,
|
||||
currentConnectionPromise: () => rejectedPromise
|
||||
})
|
||||
|
||||
await expect(revalidateRemoteConnection(rejected.options)).resolves.toEqual({ ok: true, rebuilt: false })
|
||||
expect(rejected.probe).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
182
apps/desktop/electron/remote-liveness.ts
Normal file
182
apps/desktop/electron/remote-liveness.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000
|
||||
export const REMOTE_LIVENESS_FAILURE_LIMIT = 3
|
||||
// Even at the capped retry path, consecutive liveness observations are at most
|
||||
// about 48s apart (ticket mint + socket open + backoff + the next status probe).
|
||||
// One minute keeps a continuous outage together without carrying old failures.
|
||||
export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000
|
||||
|
||||
export interface RemoteLivenessFailure {
|
||||
failures: number
|
||||
shouldReset: boolean
|
||||
}
|
||||
|
||||
interface RemoteConnectionDescriptor {
|
||||
baseUrl?: null | string
|
||||
mode?: null | string
|
||||
}
|
||||
|
||||
export interface RevalidateRemoteConnectionOptions<TConnection extends RemoteConnectionDescriptor> {
|
||||
connectionPromise: Promise<TConnection>
|
||||
currentConnectionPromise: () => null | Promise<TConnection>
|
||||
log: (message: string) => void
|
||||
probe: (url: string, options: { timeoutMs: number }) => Promise<unknown>
|
||||
resetConnection: () => void
|
||||
tracker: RemoteLivenessTracker
|
||||
}
|
||||
|
||||
export interface RemoteRevalidationResult {
|
||||
ok: true
|
||||
rebuilt: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Coalesces revalidation work for one cached connection promise.
|
||||
*
|
||||
* Every Desktop BrowserWindow owns a renderer gateway loop. When several
|
||||
* windows observe the same disconnect they can all ask the Electron main
|
||||
* process to revalidate the shared primary connection at once. Those calls
|
||||
* must count as one probe, not several consecutive failures.
|
||||
*/
|
||||
export class RemoteRevalidationCoordinator {
|
||||
readonly #inflightByConnection = new WeakMap<object, Promise<unknown>>()
|
||||
|
||||
run<T>(connection: object, task: () => Promise<T>): Promise<T> {
|
||||
const existing = this.#inflightByConnection.get(connection) as Promise<T> | undefined
|
||||
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const pending = Promise.resolve().then(task)
|
||||
|
||||
const clear = () => {
|
||||
if (this.#inflightByConnection.get(connection) === pending) {
|
||||
this.#inflightByConnection.delete(connection)
|
||||
}
|
||||
}
|
||||
|
||||
this.#inflightByConnection.set(connection, pending)
|
||||
// Clean up on both outcomes without creating an unhandled rejected branch.
|
||||
void pending.then(clear, clear)
|
||||
|
||||
return pending
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks consecutive remote liveness failures independently per gateway.
|
||||
* A successful probe clears the streak, and reaching the limit consumes it so
|
||||
* a rebuilt connection starts from a clean state.
|
||||
*/
|
||||
export class RemoteLivenessTracker {
|
||||
readonly #failureLimit: number
|
||||
readonly #failureWindowMs: number
|
||||
readonly #failuresByBaseUrl = new Map<string, { failures: number; lastFailureAt: number }>()
|
||||
readonly #now: () => number
|
||||
|
||||
constructor(
|
||||
failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT,
|
||||
failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS,
|
||||
now: () => number = Date.now
|
||||
) {
|
||||
if (!Number.isInteger(failureLimit) || failureLimit < 1) {
|
||||
throw new Error('Remote liveness failure limit must be a positive integer.')
|
||||
}
|
||||
|
||||
if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) {
|
||||
throw new Error('Remote liveness failure window must be positive.')
|
||||
}
|
||||
|
||||
this.#failureLimit = failureLimit
|
||||
this.#failureWindowMs = failureWindowMs
|
||||
this.#now = now
|
||||
}
|
||||
|
||||
recordSuccess(baseUrl: string): void {
|
||||
this.#failuresByBaseUrl.delete(baseUrl)
|
||||
}
|
||||
|
||||
recordFailure(baseUrl: string): RemoteLivenessFailure {
|
||||
const now = this.#now()
|
||||
const previous = this.#failuresByBaseUrl.get(baseUrl)
|
||||
const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs
|
||||
const failures = (withinFailureWindow ? previous.failures : 0) + 1
|
||||
const shouldReset = failures >= this.#failureLimit
|
||||
|
||||
if (shouldReset) {
|
||||
this.#failuresByBaseUrl.delete(baseUrl)
|
||||
} else {
|
||||
this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now })
|
||||
}
|
||||
|
||||
return { failures, shouldReset }
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.#failuresByBaseUrl.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe the cached primary remote connection and apply the failure policy.
|
||||
* The caller owns single-flight coordination; identity checks here ensure an
|
||||
* old async result cannot mutate or reset a replacement connection.
|
||||
*/
|
||||
export async function revalidateRemoteConnection<TConnection extends RemoteConnectionDescriptor>({
|
||||
connectionPromise,
|
||||
currentConnectionPromise,
|
||||
log,
|
||||
probe,
|
||||
resetConnection,
|
||||
tracker
|
||||
}: RevalidateRemoteConnectionOptions<TConnection>): Promise<RemoteRevalidationResult> {
|
||||
let connection: TConnection
|
||||
|
||||
try {
|
||||
connection = await connectionPromise
|
||||
} catch {
|
||||
// The cached boot already rejected; its own recovery path will clear it.
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
if (connection.mode !== 'remote' || !connection.baseUrl) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
const baseUrl = connection.baseUrl.replace(/\/+$/, '')
|
||||
|
||||
try {
|
||||
await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS })
|
||||
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
tracker.recordSuccess(baseUrl)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
} catch {
|
||||
if (currentConnectionPromise() !== connectionPromise) {
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
const failure = tracker.recordFailure(baseUrl)
|
||||
|
||||
if (!failure.shouldReset) {
|
||||
log(
|
||||
`Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.`
|
||||
)
|
||||
|
||||
return { ok: true, rebuilt: false }
|
||||
}
|
||||
|
||||
log('Cached remote Hermes backend failed liveness probe; dropping stale connection.')
|
||||
resetConnection()
|
||||
|
||||
return { ok: true, rebuilt: true }
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,12 @@ import assert from 'node:assert/strict'
|
|||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows'
|
||||
import {
|
||||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds
|
||||
} from './session-windows'
|
||||
|
||||
// A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a
|
||||
// test fire the 'closed' event, mirroring the slice of the Electron API the
|
||||
|
|
@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th
|
|||
assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc')
|
||||
})
|
||||
|
||||
test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => {
|
||||
const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true })
|
||||
test('instanceWindowBounds cascades a new window off its source bounds', () => {
|
||||
const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 })
|
||||
|
||||
assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/')
|
||||
assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 })
|
||||
})
|
||||
|
||||
test('instanceWindowBounds falls back to the persisted geometry with no source window', () => {
|
||||
const fallback = { width: 1280, height: 800 }
|
||||
|
||||
assert.equal(instanceWindowBounds(null, fallback), fallback)
|
||||
})
|
||||
|
||||
test('registry opens one window per session and focuses on re-open', () => {
|
||||
|
|
|
|||
|
|
@ -38,13 +38,12 @@ function chatWindowWebPreferences(preloadPath: string) {
|
|||
// flag MUST sit in the query string BEFORE the '#': anything after the '#' is
|
||||
// treated as the route by HashRouter and would break routeSessionId(). The
|
||||
// renderer reads the flag from window.location.search to suppress the install /
|
||||
// onboarding overlays and the global session sidebar. `new=1` marks the compact
|
||||
// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's
|
||||
// session): the renderer resumes it lazily so the gateway never builds an agent
|
||||
// just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) {
|
||||
const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}`
|
||||
const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}`
|
||||
// onboarding overlays and the global session sidebar. `watch=1` marks a
|
||||
// spectator window (e.g. a running subagent's session): the renderer resumes it
|
||||
// lazily so the gateway never builds an agent just to stream into it.
|
||||
function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) {
|
||||
const query = `?win=secondary${watch ? '&watch=1' : ''}`
|
||||
const route = `#/${encodeURIComponent(sessionId)}`
|
||||
|
||||
if (devServer) {
|
||||
const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer
|
||||
|
|
@ -55,6 +54,28 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath
|
|||
return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}`
|
||||
}
|
||||
|
||||
// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app
|
||||
// peer, not a compact chat. Cascade each one off its source window's bounds so a
|
||||
// new window doesn't land exactly on top of the one it was spawned from. Pure so
|
||||
// it's unit-testable; the Electron glue (reading the focused window's bounds,
|
||||
// constructing the BrowserWindow) stays in main.ts. `base` is the source
|
||||
// window's current bounds, or null when there's no live source window — then the
|
||||
// persisted primary geometry (`fallback`) is used as-is.
|
||||
const INSTANCE_CASCADE_OFFSET = 32
|
||||
|
||||
function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) {
|
||||
if (!base) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
return {
|
||||
width: base.width,
|
||||
height: base.height,
|
||||
x: base.x + INSTANCE_CASCADE_OFFSET,
|
||||
y: base.y + INSTANCE_CASCADE_OFFSET
|
||||
}
|
||||
}
|
||||
|
||||
// A small registry keyed by sessionId that guarantees one window per chat:
|
||||
// opening a session that already has a live window focuses it instead of
|
||||
// spawning a duplicate, and a window removes itself from the registry when it
|
||||
|
|
@ -119,6 +140,7 @@ export {
|
|||
buildSessionWindowUrl,
|
||||
chatWindowWebPreferences,
|
||||
createSessionWindowRegistry,
|
||||
instanceWindowBounds,
|
||||
SESSION_WINDOW_MIN_HEIGHT,
|
||||
SESSION_WINDOW_MIN_WIDTH
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
"scripts": {
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
|
||||
"dev:mock": "node scripts/dev-mock.mjs",
|
||||
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
|
|
@ -40,7 +41,7 @@
|
|||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
|
|
@ -49,7 +50,10 @@
|
|||
"test:desktop:platforms": "vitest run --project electron",
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
|
||||
"test:e2e": "playwright test e2e/",
|
||||
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.14.23",
|
||||
|
|
@ -124,6 +128,7 @@
|
|||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "=1.58.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
|
|
|
|||
63
apps/desktop/playwright.config.ts
Normal file
63
apps/desktop/playwright.config.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import './e2e/fix-electron-tracing'
|
||||
|
||||
import { defineConfig, type ReporterDescription } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Visual regression testing config.
|
||||
*
|
||||
* Screenshots are compared against baselines. On `main`, baselines are
|
||||
* generated with `--update-snapshots` and cached. On PRs, the cached
|
||||
* baselines are restored and screenshots are compared — but tests DON'T
|
||||
* fail on visual diffs (see `expectVisualSnapshot` in visual-snapshot.ts).
|
||||
* Instead, diffs are surfaced in the CI step summary and uploaded as
|
||||
* artifacts for human review.
|
||||
*
|
||||
* To update baselines after an intentional UI change:
|
||||
* npx playwright test --update-snapshots
|
||||
*/
|
||||
const reporters: ReporterDescription[] = [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: 'playwright-report' }],
|
||||
]
|
||||
|
||||
if (process.env.CI) {
|
||||
reporters.push(['json', { outputFile: 'playwright-report/results.json' }])
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
/* Test files live under e2e/ so they never collide with the vitest suite
|
||||
* under src/ or the node:test files under electron/. */
|
||||
testDir: './e2e',
|
||||
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
|
||||
* per test gives us headroom without masking real hangs. */
|
||||
timeout: 90_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Each test gets its own worker so the Electron process is fully isolated. */
|
||||
fullyParallel: false,
|
||||
reporter: reporters,
|
||||
use: {
|
||||
screenshot: 'on',
|
||||
trace: { mode: 'on', screenshots: true, snapshots: true, sources: true },
|
||||
// Emulate prefers-reduced-motion: reduce so all CSS transitions and
|
||||
// animations resolve instantly. This prevents boot/connecting overlays
|
||||
// from being mid-fade when a screenshot fires, and skips JS-driven exit
|
||||
// choreography in components that check matchMedia (onboarding, connecting
|
||||
// overlay, DecodeText). Without this, screenshots capture the loading bar
|
||||
// or overlay at a transient opacity because the text-content check fires
|
||||
// before the visual transition finishes.
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
// 1% of pixels may differ — absorbs sub-pixel font rendering variance
|
||||
// between local and CI environments.
|
||||
maxDiffPixelRatio: 0.01,
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
// Per-channel threshold for "close enough" — anti-aliasing differences.
|
||||
threshold: 0.2,
|
||||
},
|
||||
},
|
||||
})
|
||||
237
apps/desktop/scripts/dev-mock.mjs
Normal file
237
apps/desktop/scripts/dev-mock.mjs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Launch the desktop app with a mock inference provider — no real API
|
||||
* keys needed. Starts a local OpenAI-compatible server that returns a
|
||||
* canned reply, writes an isolated config.yaml + .env, and launches the
|
||||
* built Electron app against them.
|
||||
*
|
||||
* This reuses the same mock-server and config format as the E2E fixtures
|
||||
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
|
||||
* test the same chain.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/dev-mock.mjs
|
||||
* npm run dev:mock
|
||||
*
|
||||
* The mock server listens on an ephemeral port and replies to every
|
||||
* chat completion with:
|
||||
* "Hello from the mock inference server! The full boot chain is working."
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
|
||||
// ── Canned reply ───────────────────────────────────────────────────────
|
||||
|
||||
const CANNED_REPLY =
|
||||
'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
|
||||
|
||||
function startMockServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk.toString() })
|
||||
req.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion.chunk',
|
||||
created: 0, model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
|
||||
})}\n\n`,
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion.chunk',
|
||||
created: 0, model,
|
||||
choices: [{ index: 0, delta: { content: word }, finish_reason: null }],
|
||||
})}\n\n`,
|
||||
)
|
||||
i++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
sendChunk()
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion', object: 'chat.completion',
|
||||
created: 0, model,
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
|
||||
|
||||
function createSandbox() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
function writeMockConfig(hermesHome, mockUrl) {
|
||||
fs.writeFileSync(
|
||||
path.join(hermesHome, 'config.yaml'),
|
||||
`# Auto-generated by dev-mock.mjs
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
|
||||
}
|
||||
|
||||
// ── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
function findElectron() {
|
||||
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
if (fs.existsSync(local)) return local
|
||||
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
|
||||
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
|
||||
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
|
||||
}
|
||||
|
||||
function assertDistBuilt() {
|
||||
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
|
||||
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
|
||||
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
assertDistBuilt()
|
||||
|
||||
console.log('Starting mock inference server...')
|
||||
const mock = await startMockServer()
|
||||
console.log(` Mock server: ${mock.url}`)
|
||||
|
||||
const sandbox = createSandbox()
|
||||
writeMockConfig(sandbox.hermesHome, mock.url)
|
||||
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
|
||||
}
|
||||
|
||||
console.log('Launching Electron...')
|
||||
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
mock.close()
|
||||
sandbox.cleanup()
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
|
|
@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
|
|||
import { useEffect, useRef } from 'react'
|
||||
|
||||
import { playSpeechText } from '@/lib/voice-playback'
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $messages } from '@/store/session'
|
||||
import { $voicePlayback } from '@/store/voice-playback'
|
||||
|
|
@ -65,9 +66,16 @@ export function useAutoSpeakReplies({
|
|||
}
|
||||
|
||||
markSpoken()
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
// Only one window voices a given reply when the same chat is open in
|
||||
// several (reply.id is the shared backend message id). markSpoken already
|
||||
// ran in every window, so peers just stay quiet.
|
||||
void ownsAmbientCue(`speak:${reply.id}`).then(owns => {
|
||||
if (owns) {
|
||||
void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
|
||||
notifyError(error, failureLabel)
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Re-check on a reply completing ($messages) and on the prior clip ending
|
||||
|
|
|
|||
|
|
@ -734,7 +734,7 @@ export function ChatBar({
|
|||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={cn(
|
||||
'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed',
|
||||
'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60',
|
||||
'**:data-ref-text:cursor-default',
|
||||
stacked && 'pl-3',
|
||||
|
|
|
|||
|
|
@ -360,4 +360,13 @@ export function normalizeComposerEditorDom(editor: HTMLElement) {
|
|||
editor.removeChild(last)
|
||||
}
|
||||
}
|
||||
|
||||
// ContentEditable elements with no children can visually collapse to
|
||||
// near-zero height in some browsers (especially Chromium), causing the
|
||||
// composer to appear as a tiny dot/pixel. Ensure there's always at least
|
||||
// one <br> so the element maintains intrinsic height. The CSS min-height
|
||||
// is a belt; the <br> is suspenders — together they prevent the shrink.
|
||||
if (editor.childNodes.length === 0) {
|
||||
editor.appendChild(document.createElement('br'))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type * as React from 'react'
|
||||
import { Suspense, useCallback, useMemo } from 'react'
|
||||
import { Suspense, useCallback, useEffect, useMemo } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
|
||||
import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils'
|
||||
|
|
@ -20,6 +20,8 @@ import type { ChatMessage } from '@/lib/chat-messages'
|
|||
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
|
||||
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
|
|
@ -32,6 +34,7 @@ import {
|
|||
$introSeed,
|
||||
$resumeExhaustedSessionId,
|
||||
$sessions,
|
||||
resolveComposerSessionKey,
|
||||
sessionMatchesStoredId,
|
||||
sessionPinId
|
||||
} from '@/store/session'
|
||||
|
|
@ -276,7 +279,27 @@ export function ChatView({
|
|||
const messagesEmpty = useStore(view.$messagesEmpty)
|
||||
const lastVisibleIsUser = useStore(view.$lastVisibleIsUser)
|
||||
const selectedSessionId = useStore(view.$storedId)
|
||||
const sessions = useStore($sessions)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
|
||||
// Durable composer/queue scope (lineage root) so auto-compression tip rotation
|
||||
// does not wipe an in-progress draft or orphan /queue entries.
|
||||
const queueSessionKey = useMemo(
|
||||
() => resolveComposerSessionKey(selectedSessionId, sessions),
|
||||
[selectedSessionId, sessions]
|
||||
)
|
||||
|
||||
// When the tip row arrives after compression, migrate any tip-keyed stash onto
|
||||
// the durable lineage key before the composer remounts onto that key.
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) {
|
||||
return
|
||||
}
|
||||
|
||||
migrateSessionDraft(selectedSessionId, queueSessionKey)
|
||||
migrateQueuedPrompts(selectedSessionId, queueSessionKey)
|
||||
}, [queueSessionKey, selectedSessionId])
|
||||
|
||||
// A tile IS its session — no route involved, never "mismatched".
|
||||
const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId
|
||||
const isRoutedSessionView = Boolean(routedSessionId)
|
||||
|
|
@ -524,7 +547,7 @@ export function ChatView({
|
|||
onSteer={onSteer}
|
||||
onSubmit={onSubmit}
|
||||
onTranscribeAudio={onTranscribeAudio}
|
||||
queueSessionKey={selectedSessionId}
|
||||
queueSessionKey={queueSessionKey}
|
||||
sessionId={activeSessionId}
|
||||
state={chatBarState}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { useI18n } from '@/i18n'
|
|||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
Activity,
|
||||
AppWindow,
|
||||
Archive,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
|
|
@ -59,6 +60,7 @@ import { openPetGenerate } from '@/store/pet-generate'
|
|||
import { requestStartWorkSession } from '@/store/projects'
|
||||
import { runGatewayRestart } from '@/store/system-actions'
|
||||
import { applyBackendUpdate } from '@/store/updates'
|
||||
import { canOpenNewWindow, openNewWindow } from '@/store/windows'
|
||||
import { luminance } from '@/themes/color'
|
||||
import { type ThemeMode, useTheme } from '@/themes/context'
|
||||
import { isUserTheme, resolveTheme } from '@/themes/user-themes'
|
||||
|
|
@ -413,6 +415,18 @@ export function CommandPalette() {
|
|||
label: cc.nav.newChat.title,
|
||||
run: go(NEW_CHAT_ROUTE)
|
||||
},
|
||||
...(canOpenNewWindow()
|
||||
? [
|
||||
{
|
||||
action: 'session.newWindow',
|
||||
icon: AppWindow,
|
||||
id: 'nav-new-window',
|
||||
keywords: ['window', 'instance', 'open', 'new'],
|
||||
label: t.keybinds.actions['session.newWindow'],
|
||||
run: () => void openNewWindow()
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
action: 'view.showTerminal',
|
||||
icon: Terminal,
|
||||
|
|
|
|||
|
|
@ -159,9 +159,10 @@ export function useGatewayBoot({
|
|||
// with a short TTL, so the ticket baked into the cached conn.wsUrl is
|
||||
// dead on every reconnect after the initial boot — reusing it surfaces
|
||||
// as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl
|
||||
// mints a fresh ticket (or throws a reauth error in OAuth mode rather
|
||||
// than connecting with a stale one). For local/token gateways the URL
|
||||
// carries a long-lived token and the re-mint is a cheap no-op.
|
||||
// mints a fresh ticket rather than connecting with a stale one. An
|
||||
// explicit auth rejection asks for sign-in; transport failures stay in
|
||||
// this reconnect loop. For local/token gateways the URL carries a
|
||||
// long-lived token and the re-mint is a cheap no-op.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
|
|
@ -459,9 +460,9 @@ export function useGatewayBoot({
|
|||
publish(conn)
|
||||
// Mint a fresh WS URL right before connecting. For OAuth gateways the
|
||||
// ticket is single-use with a short TTL, so the ticket baked into
|
||||
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on
|
||||
// failure, throws a reauth error rather than connecting with a dead
|
||||
// ticket (which would surface as an opaque "connection closed").
|
||||
// conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than
|
||||
// connecting with a dead ticket. Auth rejection asks for sign-in;
|
||||
// connectivity failures remain retryable.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await gateway.connect(wsUrl)
|
||||
|
||||
|
|
|
|||
|
|
@ -69,9 +69,10 @@ export function useGatewayRequest() {
|
|||
setConnection(conn)
|
||||
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
||||
// and short-lived, so the cached conn.wsUrl ticket is dead here;
|
||||
// resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than
|
||||
// connecting with a stale ticket. Stash it so requestGateway can show
|
||||
// the actionable "sign in again" message.
|
||||
// resolveGatewayWsUrl() never connects with a stale ticket. An explicit
|
||||
// auth rejection becomes a reauth error; transport failures remain
|
||||
// retryable. Stash only the former so requestGateway can show the
|
||||
// actionable "sign in again" message.
|
||||
const wsUrl = await resolveGatewayWsUrl(desktop, conn)
|
||||
await existing.connect(wsUrl)
|
||||
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ import {
|
|||
switcherActive,
|
||||
switcherJustClosed
|
||||
} from '@/store/session-switcher'
|
||||
import { openNewSessionInNewWindow } from '@/store/windows'
|
||||
import { openNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
|
|
@ -145,7 +145,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
|
||||
},
|
||||
'session.newTab': () => deps.openNewSessionTab(),
|
||||
'session.newWindow': () => void openNewSessionInNewWindow(),
|
||||
'session.newWindow': () => void openNewWindow(),
|
||||
// ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus
|
||||
// falls through to the recent-session switcher.
|
||||
'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)),
|
||||
|
|
|
|||
|
|
@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
|
||||
flushQueuedDeltas(sessionId)
|
||||
|
||||
playCompletionSound()
|
||||
// Keyed by session so only one window beeps when several are open.
|
||||
playCompletionSound(sessionId)
|
||||
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
|||
|
||||
import { getSessionMessages, type SessionInfo } from '@/hermes'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile'
|
||||
import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects'
|
||||
import {
|
||||
|
|
@ -198,6 +199,89 @@ describe('active stored-session id rotation routing', () => {
|
|||
expect($activeSessionStoredIdRotation.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => {
|
||||
const tipBefore = 'tip-root'
|
||||
const tipAfter = 'tip-new-unloaded'
|
||||
const runtimeSessionId = 'runtime-gap'
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
|
||||
const navigate = vi.fn()
|
||||
|
||||
setSessions([])
|
||||
stashSessionDraft(tipBefore, 'typed during gap', [])
|
||||
setSelectedStoredSessionId(tipBefore)
|
||||
setActiveSessionId(runtimeSessionId)
|
||||
|
||||
render(
|
||||
<StoredIdRotationHarness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
getRoutedStoredSessionId={() => tipBefore}
|
||||
navigate={navigate}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: tipAfter,
|
||||
previousStoredSessionId: tipBefore,
|
||||
runtimeSessionId
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
|
||||
expect(takeSessionDraft(tipBefore).text).toBe('typed during gap')
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipBefore)
|
||||
clearSessionDraft(tipAfter)
|
||||
setActiveSessionId(null)
|
||||
})
|
||||
|
||||
it('parks an in-progress composer draft on the lineage root across tip rotation', async () => {
|
||||
// Desktop draft must stay on the durable composer key (lineage root), not
|
||||
// move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey.
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
const runtimeSessionId = 'runtime-desktop-thinking'
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: runtimeSessionId }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: tipBefore }
|
||||
const navigate = vi.fn()
|
||||
const typedWhileThinking = 'follow up I am still typing during thinking'
|
||||
|
||||
setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })])
|
||||
stashSessionDraft(tipBefore, typedWhileThinking, [])
|
||||
setSelectedStoredSessionId(tipBefore)
|
||||
setActiveSessionId(runtimeSessionId)
|
||||
|
||||
render(
|
||||
<StoredIdRotationHarness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
getRoutedStoredSessionId={() => tipBefore}
|
||||
navigate={navigate}
|
||||
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() => {
|
||||
setActiveSessionStoredIdRotation({
|
||||
nextStoredSessionId: tipAfter,
|
||||
previousStoredSessionId: tipBefore,
|
||||
runtimeSessionId
|
||||
})
|
||||
})
|
||||
|
||||
await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter))
|
||||
// Durable key remains the lineage root — same scope ChatBar will keep using.
|
||||
expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking)
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipBefore)
|
||||
clearSessionDraft(tipAfter)
|
||||
setActiveSessionId(null)
|
||||
setSessions([])
|
||||
})
|
||||
|
||||
it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: 'runtime-A' }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: 'stored-A' }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { useI18n } from '@/i18n'
|
|||
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { clearQueuedPrompts } from '@/store/composer-queue'
|
||||
import { migrateSessionDraft } from '@/store/composer'
|
||||
import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { clearNotifications, notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
|
|
@ -25,6 +26,7 @@ import {
|
|||
$sessions,
|
||||
$yoloActive,
|
||||
type NewChatWorkspaceTarget,
|
||||
resolveComposerSessionKey,
|
||||
sessionPinId,
|
||||
setActiveSessionId,
|
||||
setActiveSessionStoredIdRotation,
|
||||
|
|
@ -234,14 +236,35 @@ export function useSessionActions({
|
|||
return
|
||||
}
|
||||
|
||||
setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId)
|
||||
selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId
|
||||
// Park unsent draft/queue on the durable lineage key (not the new tip).
|
||||
// ChatBar scopes composer state on resolveComposerSessionKey(); migrating
|
||||
// onto the tip while the composer is still bound to the root can lose newer
|
||||
// live editor text on a brief remount. If the new tip row is not in
|
||||
// $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer
|
||||
// the previous id (usually the lineage root) in that gap.
|
||||
const previousId = storedIdRotation.previousStoredSessionId
|
||||
const nextId = storedIdRotation.nextStoredSessionId
|
||||
const sessions = $sessions.get()
|
||||
const resolvedNext = resolveComposerSessionKey(nextId, sessions)
|
||||
|
||||
const durableKey =
|
||||
resolvedNext && resolvedNext !== nextId
|
||||
? resolvedNext
|
||||
: (resolveComposerSessionKey(previousId, sessions) ?? previousId)
|
||||
|
||||
migrateSessionDraft(previousId, durableKey)
|
||||
migrateSessionDraft(nextId, durableKey)
|
||||
migrateQueuedPrompts(previousId, durableKey)
|
||||
migrateQueuedPrompts(nextId, durableKey)
|
||||
|
||||
setSelectedStoredSessionId(nextId)
|
||||
selectedStoredSessionIdRef.current = nextId
|
||||
|
||||
// A route overlay/page has no routed session id, but the underlying selected
|
||||
// chat still needs to follow the continuation. Update that selection in
|
||||
// place without navigating out of the surface the user deliberately opened.
|
||||
if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) {
|
||||
navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true })
|
||||
if (routedStoredSessionId === previousId) {
|
||||
navigate(sessionRoute(nextId), { replace: true })
|
||||
}
|
||||
}, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation])
|
||||
|
||||
|
|
|
|||
|
|
@ -32,19 +32,19 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
|||
case 'insufficient_scope':
|
||||
return {
|
||||
action: { type: 'step_up' },
|
||||
message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.',
|
||||
title: 'Terminal billing needs approval'
|
||||
message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.',
|
||||
title: 'Remote Spending needs approval'
|
||||
}
|
||||
case 'remote_spending_revoked': {
|
||||
const who =
|
||||
refusal.actor === 'admin'
|
||||
? 'An admin turned off terminal billing for this terminal.'
|
||||
: 'You turned off terminal billing for this terminal.'
|
||||
? 'An admin stopped remote spending for this terminal.'
|
||||
: 'You stopped remote spending for this terminal.'
|
||||
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
|
||||
title: 'Terminal billing was turned off'
|
||||
title: 'Remote spending was stopped'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,8 +60,9 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat
|
|||
case 'remote_spending_disabled':
|
||||
return {
|
||||
action: portalAction(refusal.portalUrl),
|
||||
message: 'Terminal billing is off for this account — an admin must enable it on the portal.',
|
||||
title: 'Terminal billing is off'
|
||||
message:
|
||||
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.",
|
||||
title: 'Remote spending is off'
|
||||
}
|
||||
|
||||
case 'role_required':
|
||||
|
|
|
|||
|
|
@ -74,7 +74,9 @@ describe('BillingSettings', () => {
|
|||
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
|
||||
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.')
|
||||
screen.getByText(
|
||||
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
|
||||
)
|
||||
).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
|
||||
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
|
||||
|
|
@ -197,10 +199,8 @@ describe('BillingSettings', () => {
|
|||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
|
||||
).toBeTruthy()
|
||||
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
|
||||
expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun
|
|||
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
|
||||
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
|
||||
{row.chips?.map(chip => (
|
||||
<Button disabled={chip.disabled} key={chip.label} size="sm" type="button" variant="outline">
|
||||
<Button
|
||||
disabled={chip.disabled}
|
||||
key={chip.label}
|
||||
onClick={chip.url ? () => openExternal(chip.url) : undefined}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{chip.label}
|
||||
</Button>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ describe('deriveBillingView', () => {
|
|||
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
|
||||
|
||||
expect(buyCredits?.description).toBe(
|
||||
'Terminal billing is off for this account — an admin must enable it on the portal.'
|
||||
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
|
||||
)
|
||||
expect(buyCredits?.chips).toBeUndefined()
|
||||
expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({
|
||||
|
|
@ -184,6 +184,103 @@ describe('deriveBillingView', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('free with catalog: tier chips render inline and open the portal', () => {
|
||||
const view = deriveBillingView(
|
||||
okBilling(todayBillingState),
|
||||
okSubscription({
|
||||
...todaySubscriptionState,
|
||||
context: 'personal',
|
||||
current: null,
|
||||
tiers: [
|
||||
{
|
||||
dollars_per_month_display: '$0',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
monthly_credits: '0',
|
||||
name: 'Free',
|
||||
tier_id: 'free',
|
||||
tier_order: 0
|
||||
},
|
||||
{
|
||||
dollars_per_month_display: '$40',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
monthly_credits: '3000',
|
||||
name: 'Ultra',
|
||||
tier_id: 'ultra',
|
||||
tier_order: 2
|
||||
},
|
||||
{
|
||||
dollars_per_month_display: '$20',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
monthly_credits: '1000',
|
||||
name: 'Plus',
|
||||
tier_id: 'plus',
|
||||
tier_order: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const subscription = view.accountRows.find(row => row.id === 'subscription')
|
||||
|
||||
expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.')
|
||||
expect(subscription?.chips).toEqual([
|
||||
{ disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: subscription?.action?.url },
|
||||
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url }
|
||||
])
|
||||
})
|
||||
|
||||
it('subscriber who can change plans: current tier marked inert, others open the portal', () => {
|
||||
const view = deriveBillingView(
|
||||
okBilling(todayBillingState),
|
||||
okSubscription({
|
||||
...todaySubscriptionState,
|
||||
context: 'personal',
|
||||
tiers: [
|
||||
{
|
||||
dollars_per_month_display: '$20',
|
||||
is_current: true,
|
||||
is_enabled: true,
|
||||
monthly_credits: '1000',
|
||||
name: 'Plus',
|
||||
tier_id: 'plus',
|
||||
tier_order: 1
|
||||
},
|
||||
{
|
||||
dollars_per_month_display: '$40',
|
||||
is_current: false,
|
||||
is_enabled: true,
|
||||
monthly_credits: '3000',
|
||||
name: 'Ultra',
|
||||
tier_id: 'ultra',
|
||||
tier_order: 2
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
|
||||
const subscription = view.accountRows.find(row => row.id === 'subscription')
|
||||
|
||||
expect(subscription?.chips).toEqual([
|
||||
{ disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' },
|
||||
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url }
|
||||
])
|
||||
})
|
||||
|
||||
it('members and team contexts get no tier chips', () => {
|
||||
const member = deriveBillingView(
|
||||
okBilling(todayBillingState),
|
||||
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
|
||||
)
|
||||
|
||||
const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
|
||||
|
||||
expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
|
||||
expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
|
||||
})
|
||||
|
||||
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
|
||||
const view = deriveBillingView(
|
||||
okBilling(todayBillingState),
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export interface BillingRowActionView {
|
|||
export interface BillingChipView {
|
||||
disabled: boolean
|
||||
label: string
|
||||
/** When set, clicking the chip opens this URL externally. */
|
||||
url?: string
|
||||
}
|
||||
|
||||
export interface BillingAccountRowView {
|
||||
|
|
@ -272,6 +274,37 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier catalog as chips for accounts that can change plans; the current plan is
|
||||
* inert, every other opens the portal where the change/start happens.
|
||||
*/
|
||||
function subscriptionTierChips(
|
||||
subscription: null | SubscriptionStateResponse,
|
||||
manageUrl: string
|
||||
): BillingChipView[] | undefined {
|
||||
// Teams have no personal subscription to sell into.
|
||||
if (!subscription?.can_change_plan || subscription.context === 'team') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const tiers = (subscription.tiers ?? [])
|
||||
.filter(tier => tier.is_enabled && tier.tier_order > 0)
|
||||
.sort((a, b) => a.tier_order - b.tier_order)
|
||||
|
||||
if (tiers.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return tiers.map(tier => {
|
||||
// Monthly credits are dollars; NAS sends a bare decimal string.
|
||||
const credits = Number((tier.monthly_credits ?? '').replace(/,/g, ''))
|
||||
const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : ''
|
||||
const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`
|
||||
|
||||
return tier.is_current ? { disabled: true, label: `✓ ${label}` } : { disabled: false, label, url: manageUrl }
|
||||
})
|
||||
}
|
||||
|
||||
function subscriptionRow(
|
||||
billing: BillingStateResponse,
|
||||
subscription: null | SubscriptionStateResponse,
|
||||
|
|
@ -283,13 +316,18 @@ function subscriptionRow(
|
|||
const value = current?.tier_name ?? fallbackPlan
|
||||
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
|
||||
const unavailable = subscriptionResult && !subscriptionResult.ok
|
||||
const chips = subscriptionTierChips(subscription, manageUrl)
|
||||
|
||||
return {
|
||||
action: { label: 'Adjust plan ↗', url: manageUrl },
|
||||
caption: unavailable
|
||||
? 'Subscription details are unavailable; opening the portal is still available.'
|
||||
: `Renews ${renewal}`,
|
||||
description: 'Review your plan and change it from the billing portal.',
|
||||
chips,
|
||||
description:
|
||||
!current && chips
|
||||
? 'Paid models need a subscription — pick a plan to start it on the portal.'
|
||||
: 'Review your plan and change it from the billing portal.',
|
||||
id: 'subscription',
|
||||
secondaryPill: 'opens portal',
|
||||
title: 'Subscription',
|
||||
|
|
@ -458,7 +496,7 @@ function deriveUsageRows(
|
|||
value: clamp01(usedFraction)
|
||||
}
|
||||
: undefined,
|
||||
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend',
|
||||
caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending',
|
||||
id: 'monthly_cap',
|
||||
title: 'Monthly spend cap',
|
||||
value
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ export function useStepUpFlow() {
|
|||
if (!result.data.granted) {
|
||||
setMessage({
|
||||
kind: 'error',
|
||||
text: 'Verification finished without granting billing management access.',
|
||||
text: 'Verification finished without allowing Remote Spending for this terminal.',
|
||||
title: 'Verification was not approved'
|
||||
})
|
||||
|
||||
|
|
@ -134,7 +134,7 @@ export function useStepUpFlow() {
|
|||
])
|
||||
setMessage({
|
||||
kind: 'success',
|
||||
text: 'Billing management access was verified.',
|
||||
text: 'Remote Spending is allowed for this terminal.',
|
||||
title: 'Verification complete'
|
||||
})
|
||||
}, [api, gateway, queryClient, unsubscribe])
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
|
@ -6,6 +7,7 @@ import { useSearchParams } from 'react-router-dom'
|
|||
import { Button } from '@/components/ui/button'
|
||||
import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { $keepAwake, setKeepAwake } from '@/store/keep-awake'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes'
|
||||
|
||||
|
|
@ -18,7 +20,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie
|
|||
import { MemoryConnect } from './memory/connect'
|
||||
import { ProviderConfigPanel } from './memory/provider-config-panel'
|
||||
import { ModelSettings, ModelSettingsSkeleton } from './model-settings'
|
||||
import { EmptyState, LoadingState, SettingsContent } from './primitives'
|
||||
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
|
|
@ -53,6 +55,7 @@ export function ConfigSettings({
|
|||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.settings.config
|
||||
const keepAwake = useStore($keepAwake)
|
||||
// The editable draft is local (debounced autosave watches it), but it's seeded
|
||||
// from — and saved back through — the shared config cache, so edits are visible
|
||||
// in the MCP/model surfaces and reopening the page doesn't reload-flash.
|
||||
|
|
@ -269,6 +272,11 @@ export function ConfigSettings({
|
|||
<ModelSettings onMainModelChanged={onMainModelChanged} />
|
||||
</div>
|
||||
)}
|
||||
{/* Device-local desktop pref (not config.yaml) — lives here since keeping
|
||||
the machine awake is a power-user knob. */}
|
||||
{activeSectionId === 'advanced' && (
|
||||
<ToggleRow checked={keepAwake} description={c.keepAwakeDesc} label={c.keepAwakeTitle} onChange={setKeepAwake} />
|
||||
)}
|
||||
{visibleFields.length === 0 ? (
|
||||
<EmptyState description={c.emptyDesc} title={c.emptyTitle} />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
|
||||
import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes'
|
||||
|
|
@ -7,7 +8,7 @@ import { SlidersHorizontal } from '@/lib/icons'
|
|||
import { notifyError } from '@/store/notifications'
|
||||
import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes'
|
||||
|
||||
import { ListRow, LoadingState, Pill } from '../primitives'
|
||||
import { ListRow, Pill } from '../primitives'
|
||||
|
||||
import { FieldControl, FieldTitle } from './field-control'
|
||||
import { ProviderConfigModal } from './provider-config-modal'
|
||||
|
|
@ -95,7 +96,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) {
|
|||
)
|
||||
}
|
||||
|
||||
return <LoadingState label="Loading memory provider settings..." />
|
||||
return <PageLoader className="min-h-24" label="Loading memory provider settings..." />
|
||||
}
|
||||
|
||||
const inlineFields = config.fields.filter(field => field.inline)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
|
|
@ -20,7 +19,7 @@ import {
|
|||
import { notify } from '@/store/notifications'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { ListRow, SectionHeading, SettingsContent } from './primitives'
|
||||
import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives'
|
||||
|
||||
const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
|
||||
|
||||
|
|
@ -28,32 +27,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str
|
|||
return <p className={cn(CAPTION, className)}>{children}</p>
|
||||
}
|
||||
|
||||
function ToggleRow(props: {
|
||||
checked: boolean
|
||||
description: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={props.label}
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
props.onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={props.description}
|
||||
title={props.label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NotificationsSettings() {
|
||||
const { t } = useI18n()
|
||||
const prefs = useStore($nativeNotifyPrefs)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type { ReactNode } from 'react'
|
|||
import { PageLoader } from '@/components/page-loader'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import type { IconComponent } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
|
|
@ -108,8 +110,50 @@ export function ListRow({
|
|||
)
|
||||
}
|
||||
|
||||
// A labelled on/off row — the canonical device-pref switch (haptic baked in).
|
||||
export function ToggleRow({
|
||||
checked,
|
||||
description,
|
||||
disabled,
|
||||
label,
|
||||
onChange
|
||||
}: {
|
||||
checked: boolean
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
label: string
|
||||
onChange: (on: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<ListRow
|
||||
action={
|
||||
<Switch
|
||||
aria-label={label}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onCheckedChange={on => {
|
||||
triggerHaptic('selection')
|
||||
onChange(on)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={description}
|
||||
title={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// The settings panels render this as the sole child of the top-padded
|
||||
// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx).
|
||||
// Cancel that top pad so the loader centers in the whole card, not just the
|
||||
// band beneath it. Inline loaders (mid-panel) should use <PageLoader> directly.
|
||||
export function LoadingState({ label }: { label: string }) {
|
||||
return <PageLoader label={label} />
|
||||
return (
|
||||
<PageLoader
|
||||
className="-mt-[calc(var(--titlebar-height)+1rem)] h-[calc(100%+var(--titlebar-height)+1rem)]"
|
||||
label={label}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Canonical implementation lives in components/ui; re-exported so the many
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => {
|
|||
const sessionA = renderIndicator()
|
||||
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(screen.getByText('5s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
|
||||
sessionA.unmount()
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
|
|
@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => {
|
|||
const sessionB = renderIndicator()
|
||||
|
||||
act(() => vi.advanceTimersByTime(3_000))
|
||||
expect(screen.getByText('3s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
|
||||
sessionB.unmount()
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
|
||||
renderIndicator()
|
||||
|
||||
expect(screen.getByText('8s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { formatElapsed } from './activity-timer'
|
||||
import { StableText } from './stable-text'
|
||||
|
||||
interface ActivityTimerTextProps {
|
||||
seconds: number
|
||||
|
|
@ -9,16 +10,16 @@ interface ActivityTimerTextProps {
|
|||
|
||||
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
|
||||
return (
|
||||
<span
|
||||
<StableText
|
||||
className={cn(
|
||||
// Tinted with --dt-midground (very low alpha) so the timer reads
|
||||
// as part of the same "live signal" cluster as the dither block /
|
||||
// arc-border / working-session dot, instead of being neutral chrome.
|
||||
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums',
|
||||
'shrink-0 text-[0.56rem] leading-none tracking-[0.02em] text-midground/55',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
</StableText>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
23
apps/desktop/src/components/chat/stable-text.tsx
Normal file
23
apps/desktop/src/components/chat/stable-text.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StableTextProps {
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text as a row of 1ch-wide cells so individual characters can't
|
||||
* shift the layout as they change (e.g. digits in a ticking timer).
|
||||
* Works with any proportional font — no need for font-mono.
|
||||
*/
|
||||
export function StableText({ children, className }: StableTextProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex', className)}>
|
||||
{children.split('').map((char, i) => (
|
||||
<span className="inline-block w-[1ch] text-center" key={i}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -35,11 +35,20 @@ function forcedPreview(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const gatewaySwitching = useStore($gatewaySwitching)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const reduce = prefersReducedMotion()
|
||||
// Under reduced motion, skip the multi-phase exit choreography (text-out →
|
||||
// hold → overlay fade) and jump straight to gone so the overlay unmounts
|
||||
// the instant the gateway opens. E2E screenshots rely on this to avoid
|
||||
// catching the overlay mid-fade.
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
// Once cold boot has completed once, never resurrect the fullscreen overlay
|
||||
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
|
||||
|
|
@ -81,9 +90,13 @@ export function GatewayConnectingOverlay() {
|
|||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
setPhase('text-out')
|
||||
// Under reduced motion, skip the multi-phase exit choreography
|
||||
// (text-out → hold → overlay fade) and jump straight to gone so the
|
||||
// overlay unmounts the instant the gateway opens. E2E screenshots
|
||||
// rely on this to avoid catching the overlay mid-fade.
|
||||
setPhase(reduce ? 'gone' : 'text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState])
|
||||
}, [phase, previewing, gatewayState, reduce])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
|||
const TICK_MS = 45
|
||||
const HOLD_TICKS = 16
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
}
|
||||
|
||||
function scrambled(tail: string, resolvedCount: number): string {
|
||||
return Array.from(tail, (ch, i) =>
|
||||
ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0]
|
||||
|
|
@ -62,6 +66,15 @@ export function DecodeText({
|
|||
return
|
||||
}
|
||||
|
||||
// Under reduced motion, skip the scramble interval and render the fully
|
||||
// resolved text immediately. The cursor blink (CSS animation) is also
|
||||
// killed by the blanket reduced-motion CSS rule.
|
||||
if (prefersReducedMotion()) {
|
||||
setTail(tailText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
|
|
|
|||
15
apps/desktop/src/global.d.ts
vendored
15
apps/desktop/src/global.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
|
||||
import type {
|
||||
PetOverlayBounds,
|
||||
PetOverlayControl,
|
||||
|
|
@ -24,15 +26,21 @@ declare global {
|
|||
// Keepalive: mark a pool profile backend as recently used so the idle
|
||||
// reaper spares it while its chat is active.
|
||||
touchBackend: (profile?: string | null) => Promise<{ ok: boolean }>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<string>
|
||||
getGatewayWsUrl: (profile?: null | string) => Promise<GatewayWsUrlResult>
|
||||
// Open (or focus) a standalone OS window for a single chat session so
|
||||
// the user can work with multiple chats side by side. Returns ok:false
|
||||
// with an error code when the sessionId is empty/invalid. `watch` opens
|
||||
// a spectator window (lazy resume — no agent build) for live-streaming
|
||||
// a running subagent's session.
|
||||
openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }>
|
||||
// Open (or focus) a compact secondary window on the new-session draft.
|
||||
openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
// Open a new full-chrome app window — a peer instance of the primary that
|
||||
// renders the complete app against the shared backend, so the user can run
|
||||
// multiple GUI windows at once.
|
||||
openWindow: () => Promise<{ ok: boolean; error?: string }>
|
||||
// Claim a one-shot cross-window ambient cue (turn-end sound / spoken
|
||||
// reply). Resolves true for the first window to claim a key, false for
|
||||
// peers — so N open windows don't all fire the same cue.
|
||||
claimAmbientCue: (key: string) => Promise<boolean>
|
||||
// The pop-out pet overlay: a transparent always-on-top window hosting only
|
||||
// the mascot. The main renderer drives it (open/close/drag + state push);
|
||||
// the overlay sends control messages back (pop-in, composer submit).
|
||||
|
|
@ -90,6 +98,7 @@ declare global {
|
|||
setTitleBarTheme?: (payload: HermesTitleBarTheme) => void
|
||||
setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void
|
||||
setTranslucency?: (payload: { intensity: number }) => void
|
||||
setKeepAwake?: (on: boolean) => void
|
||||
setPreviewShortcutActive?: (active: boolean) => void
|
||||
openExternal: (url: string) => Promise<void>
|
||||
openPreviewInBrowser?: (url: string) => Promise<void>
|
||||
|
|
|
|||
|
|
@ -225,7 +225,7 @@ export const en: Translations = {
|
|||
'nav.agents': 'Open agents',
|
||||
'session.new': 'New session',
|
||||
'session.newTab': 'New session tab',
|
||||
'session.newWindow': 'New session in window',
|
||||
'session.newWindow': 'New window',
|
||||
'session.next': 'Next session',
|
||||
'session.prev': 'Previous session',
|
||||
'session.slot.1': 'Switch to recent session 1',
|
||||
|
|
@ -523,7 +523,9 @@ export const en: Translations = {
|
|||
failedLoad: 'Settings failed to load',
|
||||
autosaveFailed: 'Autosave failed',
|
||||
imported: 'Config imported',
|
||||
invalidJson: 'Invalid config JSON'
|
||||
invalidJson: 'Invalid config JSON',
|
||||
keepAwakeTitle: 'Keep computer awake',
|
||||
keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'Paste key',
|
||||
|
|
|
|||
|
|
@ -621,7 +621,9 @@ export const ja = defineLocale({
|
|||
failedLoad: '設定の読み込みに失敗しました',
|
||||
autosaveFailed: '自動保存に失敗しました',
|
||||
imported: '設定をインポートしました',
|
||||
invalidJson: '設定 JSON が無効です'
|
||||
invalidJson: '設定 JSON が無効です',
|
||||
keepAwakeTitle: 'コンピューターをスリープさせない',
|
||||
keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: 'キーを貼り付け',
|
||||
|
|
|
|||
|
|
@ -435,6 +435,8 @@ export interface Translations {
|
|||
autosaveFailed: string
|
||||
imported: string
|
||||
invalidJson: string
|
||||
keepAwakeTitle: string
|
||||
keepAwakeDesc: string
|
||||
}
|
||||
credentials: {
|
||||
pasteKey: string
|
||||
|
|
|
|||
|
|
@ -609,7 +609,9 @@ export const zhHant = defineLocale({
|
|||
failedLoad: '設定載入失敗',
|
||||
autosaveFailed: '自動儲存失敗',
|
||||
imported: '設定已匯入',
|
||||
invalidJson: '設定 JSON 無效'
|
||||
invalidJson: '設定 JSON 無效',
|
||||
keepAwakeTitle: '保持電腦喚醒',
|
||||
keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '貼上金鑰',
|
||||
|
|
|
|||
|
|
@ -220,7 +220,7 @@ export const zh: Translations = {
|
|||
'nav.agents': '打开智能体',
|
||||
'session.new': '新建会话',
|
||||
'session.newTab': '新建会话标签',
|
||||
'session.newWindow': '在新窗口中新建会话',
|
||||
'session.newWindow': '新建窗口',
|
||||
'session.next': '下一个会话',
|
||||
'session.prev': '上一个会话',
|
||||
'session.slot.1': '切换到最近会话 1',
|
||||
|
|
@ -720,7 +720,9 @@ export const zh: Translations = {
|
|||
failedLoad: '设置加载失败',
|
||||
autosaveFailed: '自动保存失败',
|
||||
imported: '配置已导入',
|
||||
invalidJson: '配置 JSON 无效'
|
||||
invalidJson: '配置 JSON 无效',
|
||||
keepAwakeTitle: '保持电脑唤醒',
|
||||
keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。'
|
||||
},
|
||||
credentials: {
|
||||
pasteKey: '粘贴密钥',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Completion sound bank for agent turn-end cues.
|
||||
// Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1.
|
||||
|
||||
import { ownsAmbientCue } from '@/store/ambient'
|
||||
import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound'
|
||||
import { $hapticsMuted } from '@/store/haptics'
|
||||
|
||||
|
|
@ -452,13 +453,22 @@ export function previewCompletionSound(variantId?: number) {
|
|||
playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get()))
|
||||
}
|
||||
|
||||
// Plays the selected completion cue on any `message.complete`.
|
||||
export function playCompletionSound() {
|
||||
// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey
|
||||
// (the session id) so only one window beeps when several are open — the mute
|
||||
// check runs first, so a muted window never claims the cue out from under an
|
||||
// audible peer.
|
||||
export function playCompletionSound(dedupeKey?: string) {
|
||||
if ($hapticsMuted.get()) {
|
||||
return
|
||||
}
|
||||
|
||||
playVariant($completionSoundVariantId.get())
|
||||
const play = () => playVariant($completionSoundVariantId.get())
|
||||
|
||||
if (!dedupeKey) {
|
||||
return play()
|
||||
}
|
||||
|
||||
void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play())
|
||||
}
|
||||
|
||||
interface AirPuffSpec {
|
||||
|
|
|
|||
|
|
@ -12,23 +12,56 @@ describe('resolveGatewayWsUrl', () => {
|
|||
expect(getGatewayWsUrl).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('throws a reauth error instead of falling back to the stale cached ticket', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired'))
|
||||
it('uses the structured URL returned across the Electron IPC boundary', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?ticket=fresh' })
|
||||
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh')
|
||||
})
|
||||
|
||||
it('throws a reauth error when the main process reports an auth rejection', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue({
|
||||
error: '401 cookie expired',
|
||||
needsOauthLogin: true,
|
||||
ok: false
|
||||
})
|
||||
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf(
|
||||
GatewayReauthRequiredError
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the underlying mint failure as the cause', async () => {
|
||||
const cause = new Error('401 cookie expired')
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
|
||||
it('preserves the main-process auth failure as the cause', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue({
|
||||
error: '401 cookie expired',
|
||||
needsOauthLogin: true,
|
||||
ok: false
|
||||
})
|
||||
|
||||
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
|
||||
expect(error).toBeInstanceOf(GatewayReauthRequiredError)
|
||||
expect((error as GatewayReauthRequiredError).cause).toBe(cause)
|
||||
expect((error as GatewayReauthRequiredError).cause).toMatchObject({ message: '401 cookie expired' })
|
||||
})
|
||||
|
||||
it('throws a reauth error when the preload cannot mint (no method)', async () => {
|
||||
await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError)
|
||||
it('keeps a transport failure retryable instead of demanding sign-in', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue({ error: 'gateway timed out', ok: false })
|
||||
const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e)
|
||||
|
||||
expect(error).toMatchObject({ message: 'gateway timed out' })
|
||||
expect(isGatewayReauthRequired(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('rethrows an unexpected transport rejection unchanged', async () => {
|
||||
const cause = new Error('socket closed')
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(cause)
|
||||
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause)
|
||||
})
|
||||
|
||||
it('reports a missing preload method as an app capability error, not reauth', async () => {
|
||||
const error = await resolveGatewayWsUrl({}, oauthConn).catch(e => e)
|
||||
|
||||
expect(error).toMatchObject({ message: expect.stringMatching(/cannot refresh OAuth WebSocket tickets/i) })
|
||||
expect(isGatewayReauthRequired(error)).toBe(false)
|
||||
})
|
||||
|
||||
it('never returns the stale cached ticket on failure', async () => {
|
||||
|
|
@ -45,6 +78,12 @@ describe('resolveGatewayWsUrl', () => {
|
|||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
|
||||
})
|
||||
|
||||
it('uses a structured refreshed token URL when available', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?token=fresh' })
|
||||
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh')
|
||||
})
|
||||
|
||||
it('falls back to the cached URL when minting fails (token is long-lived)', async () => {
|
||||
const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient'))
|
||||
await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
IconActivity as Activity,
|
||||
IconAlertCircle as AlertCircle,
|
||||
IconAlertTriangle as AlertTriangle,
|
||||
IconAppWindow as AppWindow,
|
||||
IconArchive as Archive,
|
||||
IconArchiveOff as ArchiveOff,
|
||||
IconArrowUp as ArrowUp,
|
||||
|
|
@ -120,6 +121,7 @@ export {
|
|||
Activity,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
AppWindow,
|
||||
Archive,
|
||||
ArchiveOff,
|
||||
ArrowUp,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { StableText } from '@/components/chat/stable-text'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
|
||||
|
|
@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) {
|
|||
return () => window.clearInterval(timer)
|
||||
}, [since])
|
||||
|
||||
return since ? formatDuration(now - since) : null
|
||||
if (!since) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <StableText>{formatDuration(now - since)}</StableText>
|
||||
}
|
||||
18
apps/desktop/src/store/ambient.ts
Normal file
18
apps/desktop/src/store/ambient.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// One window owns each cross-window ambient cue (turn-end sound, spoken reply)
|
||||
// so N open full windows don't all fire it for the same backend event. The main
|
||||
// process is the race-free owner (see electron/event-dedupe.ts). Off Electron —
|
||||
// or when the bridge/claim fails — every window emits, preserving the
|
||||
// single-window behavior rather than going silent.
|
||||
export async function ownsAmbientCue(key: string): Promise<boolean> {
|
||||
const claim = window.hermesDesktop?.claimAmbientCue
|
||||
|
||||
if (!claim) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return await claim(key)
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -33,12 +33,16 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) {
|
|||
const nextProgress = clampProgress(progress.progress)
|
||||
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
|
||||
|
||||
// Don't let a late progress event (error: null) clobber a previously-set
|
||||
// boot failure — failDesktopBoot is terminal for this boot cycle.
|
||||
const error = progress.error ?? (current.running ? null : current.error)
|
||||
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
...progress,
|
||||
error: progress.error ?? null,
|
||||
error,
|
||||
progress: mergedProgress,
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(error)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|||
import type { HermesRepoStatus } from '@/global'
|
||||
|
||||
import { $repoStatus, $repoStatusLoading, refreshRepoStatus } from './coding-status'
|
||||
import { $currentCwd } from './session'
|
||||
import { $currentCwd, $selectedStoredSessionId } from './session'
|
||||
|
||||
const sampleStatus: HermesRepoStatus = {
|
||||
branch: 'feature/login',
|
||||
|
|
@ -30,6 +30,7 @@ describe('refreshRepoStatus', () => {
|
|||
vi.useFakeTimers()
|
||||
$repoStatus.set(null)
|
||||
$currentCwd.set('')
|
||||
$selectedStoredSessionId.set(null)
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
|
|
@ -117,4 +118,27 @@ describe('refreshRepoStatus', () => {
|
|||
expect($repoStatus.get()).toEqual(sampleStatus)
|
||||
expect($repoStatusLoading.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('refreshes when the stored session id changes even if the cwd is unchanged', async () => {
|
||||
const probe = vi.fn(async () => sampleStatus)
|
||||
stubProbe(probe)
|
||||
|
||||
$currentCwd.set('/repo')
|
||||
$selectedStoredSessionId.set('session-a')
|
||||
// The cwd subscription fires on the set above; drain the debounced refresh.
|
||||
vi.advanceTimersByTime(200)
|
||||
await vi.runAllTicks()
|
||||
|
||||
probe.mockClear()
|
||||
|
||||
// Switch to a different session in the SAME repo dir. The cwd atom value is
|
||||
// identical, so its subscription would not re-fire — but the stored-session
|
||||
// id did change, which must still trigger a probe so the branch label
|
||||
// tracks the new session's checked-out branch.
|
||||
$selectedStoredSessionId.set('session-b')
|
||||
vi.advanceTimersByTime(200)
|
||||
await vi.runAllTicks()
|
||||
|
||||
expect(probe).toHaveBeenCalledWith('/repo')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import type { HermesGitWorktree, HermesRepoStatus } from '@/global'
|
|||
import { desktopGit } from '@/lib/desktop-git'
|
||||
|
||||
import { $worktreeRefreshToken } from './projects'
|
||||
import { $busy, $currentCwd } from './session'
|
||||
import { $busy, $currentCwd, $selectedStoredSessionId } from './session'
|
||||
import { $workspaceChangeTick } from './workspace-events'
|
||||
|
||||
// Live working-tree status for the active session's cwd — the data backbone of
|
||||
|
|
@ -172,6 +172,13 @@ function scheduleRepoStatusRefresh(cwd?: null | string): void {
|
|||
// The active session's cwd changed (session switch / new chat) → re-probe.
|
||||
$currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd))
|
||||
|
||||
// Switching sessions can land on the same cwd but a different checked-out
|
||||
// branch (the agent ran `git checkout` in another session's terminal). The cwd
|
||||
// subscription above won't fire when the path is identical, so the branch label
|
||||
// would stay stale until a window focus or turn-settle triggers a refresh.
|
||||
// Treat the stored-session id as a structural edge in its own right.
|
||||
$selectedStoredSessionId.subscribe(() => scheduleRepoStatusRefresh())
|
||||
|
||||
// A worktree add/remove (desktop op, or the agent's out-of-band git in a settled
|
||||
// turn / a window refocus — both already bump this token) → re-probe.
|
||||
$worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh())
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
addComposerAttachment,
|
||||
clearSessionDraft,
|
||||
type ComposerAttachment,
|
||||
migrateSessionDraft,
|
||||
removeComposerAttachment,
|
||||
SESSION_DRAFTS_STORAGE_KEY,
|
||||
stashSessionDraft,
|
||||
|
|
@ -106,4 +107,29 @@ describe('session drafts', () => {
|
|||
|
||||
expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf')
|
||||
})
|
||||
|
||||
it('migrates a tip-keyed draft onto the post-compression tip', () => {
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
|
||||
stashSessionDraft(tipBefore, 'half typed while thinking', [])
|
||||
|
||||
expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true)
|
||||
expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking')
|
||||
expect(takeSessionDraft(tipBefore).text).toBe('')
|
||||
|
||||
clearSessionDraft(tipAfter)
|
||||
})
|
||||
|
||||
it('does not overwrite a non-empty destination draft during migration', () => {
|
||||
stashSessionDraft('from', 'old tip draft', [])
|
||||
stashSessionDraft('to', 'already typed on new tip', [])
|
||||
|
||||
expect(migrateSessionDraft('from', 'to')).toBe(false)
|
||||
expect(takeSessionDraft('to').text).toBe('already typed on new tip')
|
||||
expect(takeSessionDraft('from').text).toBe('old tip draft')
|
||||
|
||||
clearSessionDraft('from')
|
||||
clearSessionDraft('to')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -171,6 +171,41 @@ export function takeSessionDraft(scope: string | null | undefined): SessionDraft
|
|||
|
||||
export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', [])
|
||||
|
||||
/**
|
||||
* Move a stashed composer draft from one session key onto another.
|
||||
*
|
||||
* Auto-compression rotates the live stored tip id (root → continuation) while
|
||||
* the user may still be typing. Drafts keyed on the obsolete tip would otherwise
|
||||
* vanish from the composer when selection follows the new tip. No-op unless both
|
||||
* keys resolve, differ, and the source has content. Does not overwrite a
|
||||
* non-empty destination draft.
|
||||
*/
|
||||
export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean {
|
||||
const from = draftKey(fromKey)
|
||||
const to = draftKey(toKey)
|
||||
|
||||
if (!fromKey || !toKey || from === to) {
|
||||
return false
|
||||
}
|
||||
|
||||
const source = draftsBySession.get(from)
|
||||
|
||||
if (!source || (!source.text.trim() && source.attachments.length === 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const dest = draftsBySession.get(to)
|
||||
|
||||
if (dest && (dest.text.trim() || dest.attachments.length > 0)) {
|
||||
return false
|
||||
}
|
||||
|
||||
stashSessionDraft(toKey, source.text, source.attachments)
|
||||
clearSessionDraft(fromKey)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function setComposerDraft(value: string) {
|
||||
$composerDraft.set(value)
|
||||
}
|
||||
|
|
|
|||
33
apps/desktop/src/store/keep-awake.test.ts
Normal file
33
apps/desktop/src/store/keep-awake.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { storedBoolean } from '@/lib/storage'
|
||||
|
||||
import { $keepAwake, setKeepAwake } from './keep-awake'
|
||||
|
||||
const KEY = 'hermes.desktop.keepAwake.v1'
|
||||
const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] }
|
||||
const initialHermesDesktop = desktopWindow.hermesDesktop
|
||||
const setKeepAwakeBridge = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop']
|
||||
setKeepAwake(false)
|
||||
setKeepAwakeBridge.mockClear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
desktopWindow.hermesDesktop = initialHermesDesktop
|
||||
})
|
||||
|
||||
describe('keep-awake store', () => {
|
||||
it('persists the pref and mirrors it to the main process', () => {
|
||||
setKeepAwake(true)
|
||||
expect($keepAwake.get()).toBe(true)
|
||||
expect(storedBoolean(KEY, false)).toBe(true)
|
||||
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true)
|
||||
|
||||
setKeepAwake(false)
|
||||
expect(storedBoolean(KEY, true)).toBe(false)
|
||||
expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
29
apps/desktop/src/store/keep-awake.ts
Normal file
29
apps/desktop/src/store/keep-awake.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
/**
|
||||
* Keep-awake — stop the machine sleeping during long, unattended runs.
|
||||
*
|
||||
* A device-local preference (each computer keeps its own), off by default. This
|
||||
* atom backs the Settings → Advanced toggle and mirrors changes to the main
|
||||
* process, which owns the real power-save blocker AND its own persisted copy —
|
||||
* so a cold launch restores the blocker without the renderer visiting Settings
|
||||
* (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge
|
||||
* just no-op.
|
||||
*/
|
||||
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { persistBoolean, storedBoolean } from '@/lib/storage'
|
||||
|
||||
const KEY = 'hermes.desktop.keepAwake.v1'
|
||||
|
||||
export const $keepAwake = atom<boolean>(typeof window === 'undefined' ? false : storedBoolean(KEY, false))
|
||||
|
||||
export function setKeepAwake(on: boolean): void {
|
||||
$keepAwake.set(on)
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
$keepAwake.subscribe(on => {
|
||||
persistBoolean(KEY, on)
|
||||
window.hermesDesktop?.setKeepAwake?.(on)
|
||||
})
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
$unreadFinishedSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
mergeSessionPage,
|
||||
resolveComposerSessionKey,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
|
|
@ -85,6 +86,21 @@ describe('sessionPinId', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('resolveComposerSessionKey', () => {
|
||||
it('keeps the lineage root across compression tip rotation', () => {
|
||||
const tipBefore = '20260720_062637_ad96b3'
|
||||
const tipAfter = '20260720_071049_a28905'
|
||||
const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })]
|
||||
|
||||
expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore)
|
||||
expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore)
|
||||
})
|
||||
|
||||
it('falls back to the live id when the tip row is not loaded yet', () => {
|
||||
expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeSessionPage', () => {
|
||||
it('returns the server page untouched when there is nothing to keep', () => {
|
||||
const previous = [session({ id: 'a' }), session({ id: 'b' })]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ConnectionState } from '@hermes/shared'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
||||
|
|
@ -143,6 +144,27 @@ export const sessionMatchesStoredId = (
|
|||
storedSessionId: string
|
||||
): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId
|
||||
|
||||
/**
|
||||
* Stable composer + `/queue` scope for a selected stored session.
|
||||
*
|
||||
* Same durability rule as {@link sessionPinId}: prefer the lineage root so
|
||||
* auto-compression tip rotation does not remount the composer onto an empty
|
||||
* draft/queue key mid-keystroke. Falls back to the live id when the row is
|
||||
* not in the in-memory list yet.
|
||||
*/
|
||||
export function resolveComposerSessionKey(
|
||||
selectedSessionId: string | null | undefined,
|
||||
sessions: readonly Pick<SessionInfo, '_lineage_root_id' | 'id'>[]
|
||||
): string | null {
|
||||
if (!selectedSessionId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))
|
||||
|
||||
return row ? sessionPinId(row) : selectedSessionId
|
||||
}
|
||||
|
||||
/** Merge a fresh server session page into the in-memory list, keeping any
|
||||
* row the server omitted that we still want visible — both still-"working"
|
||||
* sessions and pinned sessions.
|
||||
|
|
@ -213,7 +235,7 @@ export function mergeSessionPage(
|
|||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $gatewayState = atom<ConnectionState>('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsTotal = atom<number>(0)
|
||||
// Cron-job sessions (source === 'cron') are fetched as their own list so the
|
||||
|
|
@ -319,7 +341,7 @@ export const $modelPickerOpen = atom(false)
|
|||
export const $sessionPickerOpen = atom(false)
|
||||
|
||||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
|
||||
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue