diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f9394572d432..38934c04b9b3 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -131,9 +131,10 @@ jobs: # (#76499 → #76562 → #76575): the mock-backend Electron window never # gets a title, so boot/chat/setup/interim specs all fail identically # regardless of the PR's diff (verified on #76573 and the docs-only - # #76582). Tracking issue: #76627 (assigned: Ari). To re-enable, - # delete the `false &&` below — nothing else changed. - if: ${{ false && (needs.detect.outputs.python_prod == 'true' || needs.detect.outputs.frontend == 'true') }} + # #76582). Tracking issue: #76627 (assigned: Ari). The lane remains + # explicitly disabled until that issue is resolved; changing this to true + # is a separate workflow admission decision. + if: false uses: ./.github/workflows/e2e-desktop.yml docs-site: @@ -219,6 +220,10 @@ jobs: # ───────────────────────────────────────────────────────────────────── all-checks-pass: name: All required checks pass + # This job executes the PR-controlled evaluator; do not inherit the + # workflow-wide permissions needed by unrelated reporting jobs. + permissions: + contents: read needs: - detect - tests @@ -247,29 +252,42 @@ jobs: outputs: needs-json: ${{ steps.evaluate.outputs.needs-json }} steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Install pinned actionlint + env: + ACTIONLINT_VERSION: 1.7.11 + ACTIONLINT_SHA256: 900919a84f2229bac68ca9cd4103ea297abc35e9689ebb842c6e34a3d1b01b0a + run: | + set -euo pipefail + archive="actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" + url="https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/${archive}" + python3 - "$archive" "$url" <<'PY' + import pathlib + import sys + import urllib.request + + destination = pathlib.Path(sys.argv[1]) + request = urllib.request.Request(sys.argv[2], headers={"User-Agent": "RecursiveIntell-actionlint"}) + with urllib.request.urlopen(request, timeout=30) as response: + destination.write_bytes(response.read()) + PY + printf '%s %s\n' "$ACTIONLINT_SHA256" "$archive" | sha256sum --check --status - + tar -xzf "$archive" actionlint + chmod 0755 actionlint + + - name: Lint GitHub Actions workflows + # The disabled Desktop lane is an explicit CI-01 applicability exception. + # Keep actionlint strict for all other syntax/expressions. + run: ./actionlint -ignore 'constant expression "false" in condition' + - 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'] - icon = '✅' if result in ('success', 'skipped') else '❌' - print(f'{icon} {name}: {result}') - if failed: - print(f'::error::{len(failed)} job(s) failed: {\", \".join(failed)}') - sys.exit(1) - print('All checks passed (or were skipped)') - " + NEEDS_JSON: ${{ toJSON(needs) }} + CLASSIFIER_JSON: ${{ toJSON(needs.detect.outputs) }} + EVENT_NAME: ${{ github.event_name }} + run: python3 scripts/ci/evaluate_required_checks.py # ───────────────────────────────────────────────────────────────────── # CI timing report: collect per-job/step durations from the GitHub API, diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index b532d3551222..1b74f1af36b7 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -28,7 +28,7 @@ jobs: MERGE_BASE=$(git merge-base origin/main HEAD) # Find any new author emails in this PR's commits - NEW_EMAILS=$(git log ${MERGE_BASE}..HEAD --format='%ae' --no-merges | sort -u) + NEW_EMAILS=$(git log "${MERGE_BASE}"..HEAD --format='%ae' --no-merges | sort -u) if [ -z "$NEW_EMAILS" ]; then echo "No new commits to check." diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index f679007810f0..6dbc9a950baa 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -42,6 +42,7 @@ jobs: # 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 + # shellcheck disable=SC2016 # backticks are literal Markdown in JSON 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 "review_status=${STATUS}" > review-status.json diff --git a/.github/workflows/infographic-check.yml b/.github/workflows/infographic-check.yml index 288f6f493a05..8b10ee4e82b7 100644 --- a/.github/workflows/infographic-check.yml +++ b/.github/workflows/infographic-check.yml @@ -54,6 +54,7 @@ jobs: if [ -n "$OFFENDERS" ]; then COUNT=$(printf '%s\n' "$OFFENDERS" | wc -l | tr -d ' ') + # shellcheck disable=SC2016 # backticks are literal Markdown in JSON STATUS='[{"source":"committed infographics","results":[{"kind":"action_required","title":"PR infographic committed to the repo","summary":"Infographic images belong in the PR description, never in git.","detail":"","how_to_fix":"Untrack the image and reference the provider URL from the PR body instead:\n```\ngit rm --cached \n```\nThen put it in the PR description:\n```\n## Infographic\n\n![slug](https://)\n```\n"}]}]' echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" echo "" diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index bd4ee6d1c92d..9a099a8d0133 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -206,6 +206,7 @@ jobs: if [ -z "$PR_NUM" ]; then # gh pr create prints the PR URL. Extract the number from it # (https://github.com///pull/). + # shellcheck disable=SC2016 # backticks are literal Markdown in CLI text PR_URL=$(gh pr create \ --head "$BOT_BRANCH" --base main \ --title 'fmt(js): `npm run fix` auto-fix' \ @@ -234,7 +235,7 @@ jobs: # Poll every 15s for up to ~10 minutes. Auto-merge will handle the # PR even if this job times out — the polling is for cleanup only # (auto-close on CI failure, conflicts, or main moving). - for i in $(seq 1 40); do + for _ in $(seq 1 40); do sleep 15 STATE=$(gh pr view "$PR_NUM" --json state --jq '.state') diff --git a/.github/workflows/lockfile-diff.yml b/.github/workflows/lockfile-diff.yml index aa72e3190459..d82449ae2d6b 100644 --- a/.github/workflows/lockfile-diff.yml +++ b/.github/workflows/lockfile-diff.yml @@ -78,7 +78,7 @@ jobs: STATUS="[]" if [ "$CHANGED" = "true" ]; then - CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + CONTENT=$(python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" < /tmp/lockfile-diff.md) 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 STATUS="[]" diff --git a/.github/workflows/publish-e2e-evidence.yml b/.github/workflows/publish-e2e-evidence.yml index c4f22c21d464..666695ab78e3 100644 --- a/.github/workflows/publish-e2e-evidence.yml +++ b/.github/workflows/publish-e2e-evidence.yml @@ -55,7 +55,7 @@ jobs: # The head-SHA match skips runs that a newer push superseded. PR_NUMBER=$(gh api -X GET "repos/$SOURCE_REPO/pulls" \ -f head="$HEAD_OWNER:$HEAD_BRANCH" -f state=open \ - --jq '.[] | select(.head.sha == $ENV.HEAD_SHA) | .number' \ + --jq ".[] | select(.head.sha == \$ENV.HEAD_SHA) | .number" \ | head -n1) if [ -z "$PR_NUMBER" ]; then echo "No open pull request has head $HEAD_OWNER:$HEAD_BRANCH at $HEAD_SHA (CI run $SOURCE_RUN_ID)." diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index d258ad731d0e..c2ff302640e3 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -124,6 +124,7 @@ jobs: done if [ "$ok" != true ] && [ "$net_fail" = true ]; then echo "::error title=uv.lock check could not reach PyPI::Registry unreachable after 3 attempts — infrastructure failure, not a stale lockfile. Re-run the job." + # shellcheck disable=SC2016 # backticks are literal Markdown in JSON review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock check could not reach PyPI","summary":"`uv lock --check` could not reach the package registry after 3 attempts. This is an infrastructure failure, not a stale lockfile.","how_to_fix":"Re-run the failed job. No change to `uv.lock` is needed."}]}]' echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" echo "review_status=${review_status}" > review-status.json @@ -159,6 +160,7 @@ 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." + # shellcheck disable=SC2016 # backticks are literal Markdown in JSON 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" echo "review_status=${review_status}" > review-status.json diff --git a/apps/desktop/e2e/production-permit.spec.ts b/apps/desktop/e2e/production-permit.spec.ts index 8f6abf7065a1..d1c535660bd9 100644 --- a/apps/desktop/e2e/production-permit.spec.ts +++ b/apps/desktop/e2e/production-permit.spec.ts @@ -8,7 +8,7 @@ import { expect, test } from './test' const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..', '..') const ARES_ROOT = REPO_ROOT -const RUST_ROOT = path.resolve(REPO_ROOT, '..', 'recursive-agent-production-permit-20260830') +const RUST_ROOT = path.resolve(REPO_ROOT, '..', 'recursive-agent') let fixture: NoProviderFixture | null = null diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 384a3e8030de..99a516a09671 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -157,7 +157,7 @@ "bippy": "0.5.43", "concurrently": "10.0.4", "cross-env": "10.1.0", - "electron": "40.10.2", + "electron": "40.10.6", "electron-builder": "^26.8.1", "esbuild": "^0.28.1", "eslint": "^9.39.4", @@ -176,7 +176,7 @@ "wait-on": "^9.0.5" }, "build": { - "electronVersion": "40.10.2", + "electronVersion": "40.10.6", "appId": "com.recursiveintell.ares", "productName": "Ares", "executableName": "Ares", diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx index d5694b6144d4..a58894991d52 100644 --- a/apps/desktop/src/app/chat/composer/controls.test.tsx +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -138,10 +138,10 @@ describe('ComposerControls shortcut tooltips', () => { await expectShortcutTooltip('Send', '↵') }) - it('keeps Send (not Steer) while a turn is running if there is a payload', async () => { + it('shows Steer while a text payload redirects a running turn', async () => { renderControls({ busy: true, busyAction: 'steer' }) - await expectShortcutTooltip('Send', '↵') + await expectShortcutTooltip('Steer the current run', '↵') }) it('shows Stop only when the composer is empty mid-turn', async () => { @@ -151,10 +151,24 @@ describe('ComposerControls shortcut tooltips', () => { }) it('shows Ctrl+Enter for Queue as the secondary mid-turn action', async () => { - renderControls({ busy: true, busyAction: 'queue' }) + renderControls({ busy: true, busyAction: 'steer' }) await expectShortcutTooltip('Queue message', 'Ctrl+↵') }) + + it('shows Queue as the primary action while compaction owns the turn', () => { + renderControls({ busy: true, busyAction: 'queue', hasComposerPayload: false }) + + expect(screen.getByLabelText('Queue message')).toBeTruthy() + expect(screen.queryByLabelText('Stop')).toBeNull() + }) + + it('keeps Send for an idle payload', () => { + renderControls({ busy: false, busyAction: 'queue', hasComposerPayload: true }) + + expect(screen.getByLabelText('Send')).toBeTruthy() + expect(screen.queryByLabelText('Queue message')).toBeNull() + }) }) describe('wake-word ear visibility', () => { diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 388df3cdc05b..dd739670ea3e 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -5,7 +5,7 @@ import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' -import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, Volume2, VolumeX } from '@/lib/icons' +import { AudioLines, Ear, EarOff, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' import { cn } from '@/lib/utils' import { $hudMode, closeHud, resetHudLayout } from '@/store/hud' import { $wakeWord, toggleWakeWord } from '@/store/wake-word' @@ -73,10 +73,16 @@ export function ComposerControls({ } const showVoicePrimary = !busy && !hasComposerPayload - // Steer is just send: a payload keeps the Send affordance mid-turn. Stop - // only when the composer is empty and a turn is running. - const showStop = busy && !hasComposerPayload - const showQueueButton = busyAction !== 'stop' && hasComposerPayload + // While busy, a text-only payload steers the live turn and an attachment or + // compaction payload queues it. An empty non-compacting composer stops the + // current turn; compaction owns the queue action even before text is entered. + const showStop = busyAction === 'stop' && busy && !hasComposerPayload + // The label and icon must reflect the same action selected by ChatBar rather + // than claiming a normal send. + const showSteer = busyAction === 'steer' && hasComposerPayload + const showQueuePrimary = busy && busyAction === 'queue' + const showQueueButton = busyAction === 'steer' && hasComposerPayload + const primaryLabel = showStop ? c.stop : showSteer ? c.steer : showQueuePrimary ? c.queueMessage : c.send // The HUD is a Spotlight bar a few hundred pixels wide, so the four separate // voice toggles fold into one menu there and leave the row to the input. A // narrow tile hits the same wall from the other direction and folds for the @@ -144,22 +150,20 @@ export function ComposerControls({ ) : ( - ) : ( - - ) - } + label={} >