diff --git a/.github/sponsor/rightcode.png b/.github/sponsor/rightcode.png
new file mode 100644
index 0000000000..0eb1effbcd
Binary files /dev/null and b/.github/sponsor/rightcode.png differ
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 32c4a30bbd..f5da60b8bb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -26,7 +26,9 @@ jobs:
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
- # Pushes to main-v2 always run everything.
+ # Pushes to main-v2 always run everything. Pull requests run the macOS and
+ # Windows legs only when `platform` is not 'false'; the rule lives in
+ # scripts/ci-platform-scope.sh and fails toward running them.
changes:
runs-on: ubuntu-latest
outputs:
@@ -34,13 +36,14 @@ jobs:
desktop: ${{ steps.filter.outputs.desktop }}
sdk: ${{ steps.filter.outputs.sdk }}
scripts: ${{ steps.filter.outputs.scripts }}
+ platform: ${{ steps.filter.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: filter
run: |
- code=true; desktop=true; sdk=true; scripts=true
+ code=true; desktop=true; sdk=true; scripts=true; platform=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
@@ -48,7 +51,7 @@ jobs:
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
- files=$(git diff --name-only "$base" HEAD)
+ files=$(git diff --name-only --no-renames "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; sdk=false; scripts=false
# Root-module CI: desktop/ is a separate module, so only the
@@ -63,9 +66,10 @@ jobs:
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
+ platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
fi
fi
- { echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
+ { echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
release-notes-scripts:
needs: changes
@@ -77,14 +81,14 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- - run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
+ - run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/withdraw-version.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -93,6 +97,9 @@ jobs:
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
+ # A pull request with no platform-relevant change moves the macOS and
+ # Windows legs onto an ubuntu runner with every step skipped: the check
+ # names the ruleset requires still report, without a scarce runner.
test:
needs: changes
if: always()
@@ -100,9 +107,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- runs-on: ${{ matrix.os }}
+ runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
- RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
+ RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -302,9 +309,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
- runs-on: ${{ matrix.os }}
+ runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
- RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
+ RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
defaults:
run:
working-directory: sdk/go
@@ -409,7 +416,7 @@ jobs:
# compile it against a real macOS toolchain.
desktop-macos:
needs: changes
- if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
+ if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: macos-latest
defaults:
run:
@@ -437,7 +444,7 @@ jobs:
# (//go:build windows), so the ubuntu leg above never compiles them.
desktop-windows:
needs: changes
- if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
+ if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: windows-latest
defaults:
run:
@@ -547,7 +554,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage-report
path: coverage.out
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 77ad778376..5430b773a2 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
- uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
- uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
- uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
+ uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
diff --git a/.github/workflows/deploy-accounts-worker.yml b/.github/workflows/deploy-accounts-worker.yml
index 69998d7b8f..89adb7529f 100644
--- a/.github/workflows/deploy-accounts-worker.yml
+++ b/.github/workflows/deploy-accounts-worker.yml
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
diff --git a/.github/workflows/deploy-crash-worker.yml b/.github/workflows/deploy-crash-worker.yml
index 8521ee09b1..aaed4f63dc 100644
--- a/.github/workflows/deploy-crash-worker.yml
+++ b/.github/workflows/deploy-crash-worker.yml
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
diff --git a/.github/workflows/deploy-forum-worker.yml b/.github/workflows/deploy-forum-worker.yml
index ddf5a9596b..42df3a7db8 100644
--- a/.github/workflows/deploy-forum-worker.yml
+++ b/.github/workflows/deploy-forum-worker.yml
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
diff --git a/.github/workflows/deploy-ops-events.yml b/.github/workflows/deploy-ops-events.yml
index 62c3e027aa..8acd06062d 100644
--- a/.github/workflows/deploy-ops-events.yml
+++ b/.github/workflows/deploy-ops-events.yml
@@ -17,7 +17,7 @@ jobs:
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
diff --git a/.github/workflows/deploy-web-studio.yml b/.github/workflows/deploy-web-studio.yml
index 85067883b3..2490c161c7 100644
--- a/.github/workflows/deploy-web-studio.yml
+++ b/.github/workflows/deploy-web-studio.yml
@@ -28,7 +28,7 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
diff --git a/.github/workflows/feedback-sync.yml b/.github/workflows/feedback-sync.yml
index 0db7fe1c92..eec060ff54 100644
--- a/.github/workflows/feedback-sync.yml
+++ b/.github/workflows/feedback-sync.yml
@@ -55,7 +55,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Sync
diff --git a/.github/workflows/release-studio.yml b/.github/workflows/release-studio.yml
index dce1307f6d..b5eb699a09 100644
--- a/.github/workflows/release-studio.yml
+++ b/.github/workflows/release-studio.yml
@@ -202,7 +202,7 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -210,12 +210,13 @@ jobs:
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
- # electron-builder shells out to dpkg-deb through fakeroot for the .deb,
- # and carries its own NSIS -- the toolchain the Wails line installed by
- # hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
+ # electron-builder shells out to dpkg-deb through fakeroot for the .deb and
+ # to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
+ # Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
+ # not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
- run: sudo apt-get update && sudo apt-get install -y fakeroot
+ run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
@@ -363,7 +364,7 @@ jobs:
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
@@ -396,7 +397,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
with:
name: ${{ matrix.artifact }}
@@ -478,7 +479,7 @@ jobs:
}
if ($failed) { exit 1 }
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
@@ -533,7 +534,7 @@ jobs:
-ExpectedSubject $env:EXPECTED_SUBJECT
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
@@ -575,7 +576,7 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -587,7 +588,7 @@ jobs:
run: pnpm install --frozen-lockfile
# The signed payload is the whole bundle, so it is packaged as it came.
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
@@ -613,7 +614,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: |
@@ -648,12 +649,12 @@ jobs:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -731,7 +732,7 @@ jobs:
}
if ($failed) { exit 1 }
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -794,7 +795,7 @@ jobs:
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
@@ -831,13 +832,13 @@ jobs:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
@@ -911,7 +912,7 @@ jobs:
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
@@ -970,28 +971,28 @@ jobs:
# Every package by its exact name, never a pattern: any job without
# secrets can upload an artifact, and a pattern would publish it. Windows
# comes from the installer signing job when it ran, else the build leg.
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-amd64
path: staging/studio-build-darwin-amd64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-arm64
path: staging/studio-build-darwin-arm64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
@@ -1034,19 +1035,19 @@ jobs:
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows-amd64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: staging/cli-archives
@@ -1068,12 +1069,12 @@ jobs:
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip) per architecture,
- # two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
+ # two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
- if [ "$count" -lt 9 ]; then
- echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
+ if [ "$count" -lt 10 ]; then
+ echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
@@ -1086,7 +1087,7 @@ jobs:
# the version it is running with nothing saying so.
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
- for required in 'ReasonixStudio-linux-*.deb' \
+ for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
@@ -1182,8 +1183,8 @@ jobs:
fi
{
printf '%s\n\n' '---'
- printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
- printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
+ printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
+ printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
for f in dist/*; do
@@ -1403,7 +1404,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
registry-url: https://registry.npmjs.org
@@ -1412,7 +1413,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.resolve.outputs.candidate != 'true'
with:
name: cli-archives
@@ -1466,7 +1467,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: cli-archives
diff --git a/.github/workflows/stale-report-sweep.yml b/.github/workflows/stale-report-sweep.yml
index e564763cd1..4f3028ed0c 100644
--- a/.github/workflows/stale-report-sweep.yml
+++ b/.github/workflows/stale-report-sweep.yml
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test sweeper
diff --git a/.github/workflows/studio-selftest-windows.yml b/.github/workflows/studio-selftest-windows.yml
index 7fcf491a48..90763d62a9 100644
--- a/.github/workflows/studio-selftest-windows.yml
+++ b/.github/workflows/studio-selftest-windows.yml
@@ -46,7 +46,7 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -99,7 +99,7 @@ jobs:
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
- - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-selftest-windows-${{ steps.version.outputs.version }}
path: dist/ReasonixStudio-windows-amd64-installer.exe
diff --git a/.github/workflows/studio.yml b/.github/workflows/studio.yml
index e173746bea..18de1a3e55 100644
--- a/.github/workflows/studio.yml
+++ b/.github/workflows/studio.yml
@@ -33,7 +33,7 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -114,7 +114,7 @@ jobs:
run_install: false
- if: steps.spa.outputs.run == 'true'
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -136,14 +136,23 @@ jobs:
python3 -m http.server 4399 --directory dist-perf >/dev/null 2>&1 &
for _ in $(seq 30); do curl -sf http://localhost:4399/perf.html >/dev/null && break; sleep 1; done
node perf/intrinsic.mjs
+ node perf/firstscreen-fonts.mjs
node perf/contrast.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/selfcheck.mjs branch-tooltip
+ # A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
+ # moves the Windows and macOS legs onto an ubuntu runner with every step
+ # skipped, so the check names still report without a scarce runner. A push to
+ # studio, or a failed `scope`, runs all three.
desktop:
name: desktop (${{ matrix.os }})
- runs-on: ${{ matrix.os }}
+ needs: scope
+ if: always()
+ runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
+ env:
+ RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
strategy:
fail-fast: false
matrix:
@@ -152,9 +161,11 @@ jobs:
run:
working-directory: desktop
steps:
- - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ - if: env.RUN_STEPS == 'true'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
+ - if: env.RUN_STEPS == 'true'
+ uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -167,6 +178,7 @@ jobs:
# carries build dependencies of its own.
# Same command as `make studio-test`; this job is already in desktop/.
- name: go test
+ if: env.RUN_STEPS == 'true'
run: go test ./...
# The host sits in the main module and nothing else builds it, so its
@@ -174,7 +186,7 @@ jobs:
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
# is host-independent, so once is enough.
- name: host stays pure Go and platform-neutral
- if: runner.os == 'Linux'
+ if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
working-directory: .
run: |
for os in windows linux darwin; do
@@ -183,12 +195,15 @@ jobs:
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
# only when it touches what the leg verifies; a push to studio always does.
+ # The macOS and Windows package legs follow `platform`, the same rule as the
+ # desktop matrix above.
scope:
name: package matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
arm64: ${{ steps.pick.outputs.arm64 }}
+ platform: ${{ steps.pick.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -200,9 +215,11 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
arm64=true
+ platform=true
if [ "$EVENT" = pull_request ]; then
arm64=false
- if git diff --name-only "$BASE_SHA" HEAD |
+ platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
+ if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
@@ -212,13 +229,18 @@ jobs:
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
]'
- if [ "$arm64" = true ]; then
+ if [ "$platform" = false ]; then
+ legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
+ elif [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
- echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
- echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
+ {
+ echo "arm64=$arm64"
+ echo "platform=$platform"
+ echo "matrix={\"include\":$legs}"
+ } >> "$GITHUB_OUTPUT"
# The packaging contract, on real artifacts rather than on the config that
# describes them. Unsigned on purpose: signing and notarization are a
@@ -250,16 +272,17 @@ jobs:
version: 10
run_install: false
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/electron/pnpm-lock.yaml
- # electron-builder shells out to dpkg-deb through fakeroot for the .deb.
+ # electron-builder shells out to dpkg-deb through fakeroot for the .deb and
+ # to rpmbuild for the .rpm.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
- run: sudo apt-get update && sudo apt-get install -y fakeroot
+ run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
- name: Build the SPA
working-directory: desktop/frontend-next
@@ -314,6 +337,27 @@ jobs:
test -n "$dir"
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
+ # An update starts the installer with --updated, and that is what keeps the
+ # shortcuts (and the taskbar pin hanging off them) from being deleted and
+ # recreated. Installed twice over itself the way an update does it, the
+ # desktop shortcut has to still be the same file, pointing at a path with
+ # no version in it, or every update costs the user their pin.
+ - name: An update keeps the shortcuts and they name no version
+ if: matrix.platform == 'windows'
+ shell: pwsh
+ run: |
+ $installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
+ $dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
+ Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
+ $lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
+ $target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
+ if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
+ $mark = [datetime]'2020-01-01'
+ (Get-Item $lnk).LastWriteTime = $mark
+ Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
+ if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
+ Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
+
# The release packages a signed copy of the bundle and then reads both
# packages back against it. The same recipe on an unsigned copy, so the
# packaging contract (the installer's one application archive, the pinned
@@ -369,7 +413,7 @@ jobs:
- name: Keep the arm64 installer for the x64 refusal check
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-arm64-installer-ci
path: dist/ReasonixStudio-windows-arm64-installer.exe
@@ -416,6 +460,16 @@ jobs:
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
exit 1
fi
+ # The helper in the package accepts one package name, compiled in. It has
+ # to be the one the package carries, or every in-app update is refused
+ # as a different package after the download and signature succeed.
+ dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
+ chmod +x "$RUNNER_TEMP/update-helper"
+ accepts=$("$RUNNER_TEMP/update-helper" package)
+ if [ "$accepts" != "$name" ]; then
+ echo "::error::the update helper in the package accepts $accepts but the package is $name"
+ exit 1
+ fi
dpkg-deb -c "$deb" > contents.txt
# The path is the polkit action's exec.path annotation, so it is the
# only one authorization is granted for; root because polkit will not
@@ -433,6 +487,31 @@ jobs:
exit 1
fi
+ # The .rpm is read back for the same two properties as the .deb: it is the
+ # package update.StudioLine names, and the helper pkexec runs as root is
+ # owned by root and writable by no one else, or any local user could
+ # replace it.
+ - name: The .rpm is Studio's own package and carries what updates it
+ if: runner.os == 'Linux'
+ run: |
+ rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
+ name=$(rpm -qp --qf '%{NAME}' "$rpm")
+ if [ "$name" != "reasonix-studio" ]; then
+ echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
+ exit 1
+ fi
+ rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
+ if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
+ echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
+ grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
+ exit 1
+ fi
+ if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
+ echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
+ grep polkit contents.txt || echo "(not in the package at all)"
+ exit 1
+ fi
+
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name: The .deb's Go binaries are statically linked
@@ -480,7 +559,7 @@ jobs:
if: needs.scope.outputs.arm64 == 'true'
runs-on: windows-latest
steps:
- - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-arm64-installer-ci
path: installer
diff --git a/.github/workflows/update-acknowledgments.yml b/.github/workflows/update-acknowledgments.yml
index efe00ed826..2c4f0ee453 100644
--- a/.github/workflows/update-acknowledgments.yml
+++ b/.github/workflows/update-acknowledgments.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Update README contributor tables
diff --git a/.github/workflows/update-star-history.yml b/.github/workflows/update-star-history.yml
index 9f0bb434c3..42a1f8692c 100644
--- a/.github/workflows/update-star-history.yml
+++ b/.github/workflows/update-star-history.yml
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
+ - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test chart generator
diff --git a/.github/workflows/withdraw-studio-version.yml b/.github/workflows/withdraw-studio-version.yml
new file mode 100644
index 0000000000..4d38a986d4
--- /dev/null
+++ b/.github/workflows/withdraw-studio-version.yml
@@ -0,0 +1,84 @@
+name: Withdraw a Studio version
+
+# Pulls a published Studio release out of the update catalog (or puts it back),
+# so installs stop being offered it without waiting for a new release. Only the
+# catalog changes: the release's files stay where they are, and a withdrawn
+# release can be restored with the same run.
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Version to withdraw or restore, for example 2.36.0"
+ required: true
+ type: string
+ reason:
+ description: "Why it is withdrawn (shown to users on that version); ignored when restoring"
+ required: false
+ type: string
+ restore:
+ description: "Put a withdrawn version back instead of withdrawing it"
+ required: false
+ type: boolean
+ default: false
+
+concurrency:
+ group: studio-catalog
+ cancel-in-progress: false
+
+jobs:
+ withdraw:
+ name: ${{ inputs.restore && 'restore' || 'withdraw' }} ${{ inputs.version }}
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment: studio-release
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: studio
+ persist-credentials: false
+
+ - name: Update the Studio catalog
+ env:
+ VERSION: ${{ inputs.version }}
+ REASON: ${{ inputs.reason }}
+ RESTORE: ${{ inputs.restore }}
+ R2_BUCKET: ${{ secrets.R2_BUCKET }}
+ HAS_R2: ${{ secrets.R2_ACCESS_KEY_ID != '' && secrets.R2_SECRET_ACCESS_KEY != '' && secrets.R2_ACCOUNT_ID != '' && secrets.R2_BUCKET != '' }}
+ run: |
+ if [ "$HAS_R2" != "true" ]; then
+ echo "::error::R2 credentials are not configured, so the catalog cannot be changed"
+ exit 1
+ fi
+ if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]]; then
+ echo "::error::version must look like 2.36.0"
+ exit 1
+ fi
+ aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
+ aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
+ aws configure set default.region auto
+ endpoint="https://${{ secrets.R2_ACCOUNT_ID }}.r2.cloudflarestorage.com"
+ at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ current=/tmp/studio-versions-current.json
+ next=/tmp/studio-versions-next.json
+ aws s3 cp "s3://${R2_BUCKET}/studio/versions.json" "$current" --endpoint-url "$endpoint"
+ if [ "$RESTORE" = "true" ]; then
+ bash scripts/withdraw-version.sh "$current" --restore "$VERSION" "$at" > "$next"
+ jq -e --arg v "$VERSION" '.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 1' "$next" >/dev/null
+ else
+ bash scripts/withdraw-version.sh "$current" "$VERSION" "$REASON" "$at" > "$next"
+ jq -e --arg v "$VERSION" '(.versions | map(select((.version | ltrimstr("v")) == $v)) | length == 0) and (.withdrawn | map(select((.version | ltrimstr("v")) == $v)) | length == 1)' "$next" >/dev/null
+ fi
+ aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
+ --endpoint-url "$endpoint" \
+ --content-type "application/json; charset=utf-8" \
+ --cache-control "public, max-age=300, stale-if-error=86400"
+ echo "Studio catalog updated for $VERSION (restore=$RESTORE)"
+
+ - name: Keep the GitHub release off the latest slot
+ if: ${{ !inputs.restore }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ inputs.version }}
+ run: gh release edit "studio-v${VERSION}" --prerelease --latest=false --repo "$GITHUB_REPOSITORY"
diff --git a/README.md b/README.md
index e243dd7f04..0112e8d9d6 100644
--- a/README.md
+++ b/README.md
@@ -59,7 +59,7 @@
## ❤️Sponsor
-
Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.
+
Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support. Register via this link.
Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code. Register via this link.
## What it is
@@ -278,9 +278,9 @@ A small list of folks whose work has shaped Reasonix the most — the current to
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
-| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
-| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
-| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
+| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
+| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
+| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ec380a2159..47d387035d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -57,7 +57,7 @@
## ❤️赞助商
-
感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。
+
感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过此链接注册。
感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过此链接注册。
## 它是什么
@@ -260,9 +260,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
-| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
-| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
-| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
+| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**XTLine**](https://github.com/XTLine) | [**eghrhegpe**](https://github.com/eghrhegpe) |
+| [**noreply**](https://github.com/noreply) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**xxoingr**](https://github.com/xxoingr) | [**Bernardxu123**](https://github.com/Bernardxu123) |
+| [**HaoyueQin**](https://github.com/HaoyueQin) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) | [**JesonChou**](https://github.com/JesonChou) |
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
diff --git a/REASONIX.md b/REASONIX.md
index 37e28cfd11..2547383451 100644
--- a/REASONIX.md
+++ b/REASONIX.md
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/ext/pluginpkg/**
- sensitive: internal/base/netclient/**
- sensitive: internal/safety/redirectguard/**
+- sensitive: internal/safety/endpointclient/**
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
What follows bounds Observable today. It is not a restatement of the invariant:
moving a number here must not move the promise.
-- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
- `complete` only below 50k files. Over that, or past a walk error, it
+- `scanWorkspace` walks the workspace and every granted extra root recursively,
+ skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
establishes nothing and the mutation stays unproven.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
diff --git a/benchmarks/README.md b/benchmarks/README.md
index 41f9a2120a..564c09fcf8 100644
--- a/benchmarks/README.md
+++ b/benchmarks/README.md
@@ -420,7 +420,7 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks//`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
-| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
+| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR//trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
diff --git a/benchmarks/context-retrieval/README.md b/benchmarks/context-retrieval/README.md
index 8f81c80bf4..ba1f7be3ae 100644
--- a/benchmarks/context-retrieval/README.md
+++ b/benchmarks/context-retrieval/README.md
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
-**Fold index marginal utility: unresolved.** Both the positive and the negative
-estimates are invalidated. The first Stage 2 ran in a contaminated environment.
-The clean paired batch found a consistent effect (six of six tasks searched less
-and paged in less with the cue visible), and the same-batch dose-response did
-not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
-The most likely reason is that two of the six index tasks carry heavy-tail
-stopping behaviour that swamps the effect being measured.
+**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
+substrate the direction holds in six of six tasks and in the pooled paired batch: with the
+cue visible the index addresses the target, so the same task is searched less and pages in
+less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
+absent).
-Existing evidence is not sufficient to tune the shipped 1% budget in either
-direction. Reopening this wants a new corpus, not more samples: that substrate
-now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
+A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
+out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
+(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
+`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
+**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
+absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
+together 80% of the absent-cue total), and the qualification above does not screen for
+stopping behaviour — holding on both boundary arms across three passes shows a stable cue
+boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
+sample after seeing the result; a batch screened on stopping stability is what would make
+a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
+prompt tokens over three escapes).
+
+Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
+the token totals here cover the 24-run index batch — the paired batch ran before the
+per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
diff --git a/benchmarks/context-retrieval/batch.go b/benchmarks/context-retrieval/batch.go
new file mode 100644
index 0000000000..18ddac6889
--- /dev/null
+++ b/benchmarks/context-retrieval/batch.go
@@ -0,0 +1,145 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+)
+
+// batchInfo is what the mode itself knows before any run happens. Everything
+// else in the manifest is derived from the runs that were written, so a figure
+// in it cannot drift from the artifact beside it.
+type batchInfo struct {
+ Mode string
+ Planned int
+ Started time.Time
+ Dry bool
+ // Repo is the checkout the batch ran from, captured before the harness
+ // isolates its workspace: git cannot describe a directory that is gone.
+ Repo string
+}
+
+// batchManifest is written beside results.jsonl so a later reader does not have
+// to reconstruct what the batch was from a terminal scrollback.
+type batchManifest struct {
+ SchemaVersion int `json:"schema_version"`
+ Mode string `json:"mode"`
+ StartedAt string `json:"started_at"`
+ FinishedAt string `json:"finished_at"`
+ GitCommit string `json:"git_commit"`
+ GitDirty *bool `json:"git_dirty,omitempty"`
+ Model string `json:"model"`
+ Window int `json:"window"`
+ Generations int `json:"generations"`
+ TaskIDs []string `json:"task_ids"`
+ Arms []string `json:"arms"`
+ PlannedRuns int `json:"planned_runs"`
+ CompletedRuns int `json:"completed_runs"`
+ // Usage totals are absent when nothing reported any: an unmeasured cost must
+ // not read as a measured zero. UsageReportedRuns stays, because zero there
+ // is a fact about the batch.
+ UsageReportedRuns int `json:"usage_reported_runs"`
+ UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
+ PromptTokens int `json:"prompt_tokens,omitempty"`
+ CompletionTokens int `json:"completion_tokens,omitempty"`
+ CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
+ CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
+ UsageEstimated bool `json:"usage_estimated,omitempty"`
+ ResultsSHA256 string `json:"results_sha256"`
+ TrajectoriesSHA256 string `json:"trajectories_sha256"`
+}
+
+// gitCommitAndDirty reports the checkout the batch ran from. Unknown is written
+// rather than omitted, so an absent field is never mistaken for a clean tree.
+func gitCommitAndDirty(repo string) (commit string, dirty *bool) {
+ commit = "unknown"
+ if repo == "" {
+ return commit, nil
+ }
+ in := func(args ...string) ([]byte, error) {
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ return cmd.Output()
+ }
+ if out, err := in("rev-parse", "--short", "HEAD"); err == nil {
+ commit = strings.TrimSpace(string(out))
+ }
+ if out, err := in("status", "--porcelain"); err == nil {
+ state := strings.TrimSpace(string(out)) != ""
+ dirty = &state
+ }
+ return commit, dirty
+}
+
+func fileSHA256(path string) (string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return "", err
+ }
+ return fmt.Sprintf("%x", sha256.Sum256(data)), nil
+}
+
+func sortedKeys(set map[string]bool) []string {
+ out := make([]string, 0, len(set))
+ for k := range set {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// writeBatchManifest records the batch beside the runs it describes. It runs
+// last, so its digests are of what is already on disk.
+func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
+ ids, arms := map[string]bool{}, map[string]bool{}
+ var prompt, completion, cacheHit, cacheMiss, requests, reported int
+ estimated := false
+ for _, m := range all {
+ ids[m.Task] = true
+ arms[m.Arm] = true
+ if m.UsageReportedRequests > 0 {
+ reported++
+ requests += m.UsageReportedRequests
+ prompt += m.PromptTokens
+ completion += m.CompletionTokens
+ cacheHit += m.CacheHitTokens
+ cacheMiss += m.CacheMissTokens
+ estimated = estimated || m.UsageEstimated
+ }
+ }
+ commit, dirty := gitCommitAndDirty(info.Repo)
+ model := runModel
+ if info.Dry {
+ model = "scripted"
+ }
+ manifest := batchManifest{
+ SchemaVersion: 1, Mode: info.Mode,
+ StartedAt: info.Started.UTC().Format(time.RFC3339),
+ FinishedAt: time.Now().UTC().Format(time.RFC3339),
+ GitCommit: commit, GitDirty: dirty,
+ Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
+ TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
+ PlannedRuns: info.Planned, CompletedRuns: len(all),
+ UsageReportedRuns: reported, UsageReportedRequests: requests,
+ PromptTokens: prompt, CompletionTokens: completion,
+ CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
+ }
+ var err error
+ if manifest.ResultsSHA256, err = fileSHA256(filepath.Join(root, "results.jsonl")); err != nil {
+ return err
+ }
+ if manifest.TrajectoriesSHA256, err = fileSHA256(filepath.Join(root, "trajectories.jsonl")); err != nil {
+ return err
+ }
+ data, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(filepath.Join(root, "batch.json"), append(data, '\n'), 0o644)
+}
diff --git a/benchmarks/context-retrieval/batch_test.go b/benchmarks/context-retrieval/batch_test.go
new file mode 100644
index 0000000000..7c7fc04a0f
--- /dev/null
+++ b/benchmarks/context-retrieval/batch_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+ "crypto/sha256"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+func seedArtifacts(t *testing.T, root string) {
+ t.Helper()
+ for _, name := range []string{"results.jsonl", "trajectories.jsonl"} {
+ if err := os.WriteFile(filepath.Join(root, name), []byte("{}\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+func readManifest(t *testing.T, root string) (batchManifest, string) {
+ t.Helper()
+ data, err := os.ReadFile(filepath.Join(root, "batch.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var got batchManifest
+ if err := json.Unmarshal(data, &got); err != nil {
+ t.Fatal(err)
+ }
+ return got, string(data)
+}
+
+func TestBatchManifestRecordsWhatTheBatchWas(t *testing.T) {
+ root := t.TempDir()
+ seedArtifacts(t, root)
+ all := []contextMetrics{
+ {Task: "b", Arm: "index-off"},
+ {Task: "a", Arm: "index-off"},
+ {Task: "a", Arm: "index-half", UsageReportedRequests: 2, PromptTokens: 100,
+ CompletionTokens: 10, CacheHitTokens: 60, CacheMissTokens: 40},
+ }
+ if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 4, Started: time.Now().Add(-time.Minute)}, all); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := readManifest(t, root)
+ if got.SchemaVersion != 1 || got.Mode != "run-index" || got.PlannedRuns != 4 || got.CompletedRuns != 3 {
+ t.Fatalf("manifest header = %+v", got)
+ }
+ if strings.Join(got.TaskIDs, ",") != "a,b" || strings.Join(got.Arms, ",") != "index-half,index-off" {
+ t.Fatalf("ids/arms = %v / %v, want sorted and unique", got.TaskIDs, got.Arms)
+ }
+ if got.UsageReportedRuns != 1 || got.UsageReportedRequests != 2 || got.PromptTokens != 100 ||
+ got.CompletionTokens != 10 || got.CacheHitTokens != 60 || got.CacheMissTokens != 40 {
+ t.Fatalf("usage totals = %+v", got)
+ }
+ // No repo means git could not describe the checkout: unknown commit, and no
+ // dirty flag at all, because "clean" is a claim this batch cannot make.
+ if got.GitCommit != "unknown" || got.GitDirty != nil {
+ t.Fatalf("git state = %q / %v, want unknown and no dirty flag", got.GitCommit, got.GitDirty)
+ }
+ want := fmt.Sprintf("%x", sha256.Sum256([]byte("{}\n")))
+ if got.ResultsSHA256 != want || got.TrajectoriesSHA256 != want {
+ t.Fatalf("digests = %q / %q, want %q", got.ResultsSHA256, got.TrajectoriesSHA256, want)
+ }
+}
+
+// A dry batch pays nothing, so its cost fields must be absent rather than zero:
+// an unmeasured spend must not read as a measured one.
+func TestBatchManifestLeavesUnreportedUsageAbsent(t *testing.T) {
+ root := t.TempDir()
+ seedArtifacts(t, root)
+ all := []contextMetrics{{Task: "a", Arm: "index-off"}, {Task: "a", Arm: "index-half"}}
+ if err := writeBatchManifest(root, batchInfo{Mode: "run-index", Planned: 2, Started: time.Now(), Dry: true}, all); err != nil {
+ t.Fatal(err)
+ }
+ got, raw := readManifest(t, root)
+ if got.UsageReportedRuns != 0 {
+ t.Fatalf("usage_reported_runs = %d, want 0 reported", got.UsageReportedRuns)
+ }
+ if got.Model != "scripted" {
+ t.Fatalf("dry batch model = %q, want scripted", got.Model)
+ }
+ for _, absent := range []string{"usage_reported_requests", "prompt_tokens", "completion_tokens", "cache_hit_tokens"} {
+ if strings.Contains(raw, absent) {
+ t.Fatalf("a dry batch wrote %q:\n%s", absent, raw)
+ }
+ }
+}
diff --git a/benchmarks/context-retrieval/contextbench.go b/benchmarks/context-retrieval/contextbench.go
index aeb0026c70..9119e4940a 100644
--- a/benchmarks/context-retrieval/contextbench.go
+++ b/benchmarks/context-retrieval/contextbench.go
@@ -89,6 +89,15 @@ type contextMetrics struct {
// resident prefix; this is what is paid dynamically instead.
RecallReturnedTokens int `json:"recall_returned_tokens"`
+ // Provider usage as reported on the stream, or empty for a dry run. The count
+ // is completions that reported usage, so it is not a count of attempts.
+ UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
+ PromptTokens int `json:"prompt_tokens,omitempty"`
+ CompletionTokens int `json:"completion_tokens,omitempty"`
+ CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
+ CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
+ UsageEstimated bool `json:"usage_estimated,omitempty"`
+
Searches []searchAttempt `json:"searches,omitempty"`
// PostHitSearches are queries issued after the target had already been
// returned: not a query problem, a result-to-read problem.
diff --git a/benchmarks/context-retrieval/main.go b/benchmarks/context-retrieval/main.go
index af5628e4f4..da12343bdb 100644
--- a/benchmarks/context-retrieval/main.go
+++ b/benchmarks/context-retrieval/main.go
@@ -54,9 +54,9 @@ func main() {
case "preflight":
os.Exit(runPreflight(tasks, root))
case "run-search":
- os.Exit(runExperiment(experimentSearch, root, *dry, tasks))
+ os.Exit(runExperiment(*mode, experimentSearch, root, *dry, tasks))
case "run-boundary":
- os.Exit(runBoundaries(root))
+ os.Exit(runBoundaries(*mode, root))
case "snippet":
os.Exit(runSnippetAudit(root, 100))
case "adversarial":
@@ -69,7 +69,7 @@ func main() {
// stays measurable on its own.
batch = efficiencyTasks()
}
- os.Exit(runExperiment(experimentIndex, root, *dry, batch))
+ os.Exit(runExperiment(*mode, experimentIndex, root, *dry, batch))
default:
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
os.Exit(2)
diff --git a/benchmarks/context-retrieval/run.go b/benchmarks/context-retrieval/run.go
index 405cc70ccc..43b273333b 100644
--- a/benchmarks/context-retrieval/run.go
+++ b/benchmarks/context-retrieval/run.go
@@ -14,7 +14,6 @@ import (
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
- "reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
@@ -132,10 +131,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
+ var usage usageSink
a := agent.New(p, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
- }, event.Discard)
+ }, &usage)
a.LoadProjectionSidecar(f.Path)
// From here the transcript lives only in memory. On disk it is the answer.
sealFixture(f.Path)
@@ -161,6 +161,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
appended := sess.Snapshot()[before:]
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
m.trajectory = appended
+ if u, requests := usage.snapshot(); requests > 0 {
+ m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
+ m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
+ }
m.scoreAnswer(finalAnswer(appended), inst)
if runErr != nil {
m.FailureStage = "RunError"
@@ -212,7 +216,10 @@ func isolateWorkspace() (func(), error) {
// The two arms get separate nonces: reusing one would leave the first arm's
// answer in a host-readable history for the second to find.
-func runBoundaries(root string) int {
+func runBoundaries(mode, root string) int {
+ started := time.Now()
+ planned := 0
+ repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -239,6 +246,7 @@ func runBoundaries(root string) int {
has bool
}{{cue, true}, {noCue, false}} {
name := "index-" + side.scale
+ planned++
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
@@ -249,14 +257,14 @@ func runBoundaries(root string) int {
} else {
noCueSide = append(noCueSide, m)
}
- fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
+ fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
- m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
+ m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
}
}
reportDeltas(cueSide, noCueSide)
fmt.Print(reportStopping(all))
- return writeResults(root, all)
+ return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Repo: repo}, all)
}
// reportDeltas is the paired reading: each task against itself, then pooled.
@@ -298,7 +306,10 @@ func deltaBool(a, b bool) string {
// runExperiment runs one experiment's tasks across its arms. A dry run drives
// the same pipeline with a scripted provider: it proves fixture, run, scoring
// and report hold together before any of it is paid for.
-func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
+func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask) int {
+ started := time.Now()
+ planned := 0
+ repo, _ := os.Getwd()
root, err := filepath.Abs(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -334,15 +345,16 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
// test: a bad minute would land entirely on one arm.
for _, arm := range shuffledArms(t) {
cueVisible := t.Experiment == experimentIndex && cueExpectedVisible(t, arm.scale)
+ planned++
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
if err != nil {
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
}
byArm[arm.name] = append(byArm[arm.name], m)
all = append(all, m)
- fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
+ fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
- m.RecallReturnedTokens, m.UnexpectedWorkTools)
+ m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
}
}
reportFunnels(byArm)
@@ -355,7 +367,7 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
if experiment == experimentIndex {
reportBoundaries(all)
}
- return writeResults(root, all)
+ return writeResults(root, batchInfo{Mode: mode, Planned: planned, Started: started, Dry: dry, Repo: repo}, all)
}
func cueExpectedVisible(t contextTask, scale string) bool {
@@ -410,7 +422,7 @@ func writeTrajectories(root string, all []contextMetrics) error {
return nil
}
-func writeResults(root string, all []contextMetrics) int {
+func writeResults(root string, info batchInfo, all []contextMetrics) int {
path := filepath.Join(root, "results.jsonl")
f, err := os.Create(path)
if err != nil {
@@ -428,7 +440,11 @@ func writeResults(root string, all []contextMetrics) int {
fmt.Fprintln(os.Stderr, "write trajectories:", err)
return 2
}
- fmt.Printf("\n%d runs written to %s (with trajectories.jsonl)\n", len(all), path)
+ if err := writeBatchManifest(root, info, all); err != nil {
+ fmt.Fprintln(os.Stderr, "write batch manifest:", err)
+ return 2
+ }
+ fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
return 0
}
diff --git a/benchmarks/context-retrieval/usage.go b/benchmarks/context-retrieval/usage.go
new file mode 100644
index 0000000000..abd0c2b05b
--- /dev/null
+++ b/benchmarks/context-retrieval/usage.go
@@ -0,0 +1,59 @@
+package main
+
+import (
+ "fmt"
+ "sync"
+
+ "reasonix/internal/contract/event"
+ "reasonix/internal/contract/provider"
+)
+
+// usageSink reads what a run cost from the events the agent already emits. Every
+// billed completion reports event.Usage — the round path, compaction, command
+// routing and failure diagnosis all do — so the bench reads the same record the
+// product reads instead of wrapping a provider and counting a second time.
+type usageSink struct {
+ mu sync.Mutex
+ requests int
+ usage provider.Usage
+}
+
+func (s *usageSink) Emit(e event.Event) {
+ if e.Kind != event.Usage || e.Usage == nil {
+ return
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ // One event is one completion that reported usage; RequestCount covers a
+ // completion that merged attempts. A request that failed before reporting
+ // leaves no event, so this is not a count of attempts.
+ if e.Usage.RequestCount > 0 {
+ s.requests += e.Usage.RequestCount
+ } else {
+ s.requests++
+ }
+ u := *e.Usage
+ s.usage.PromptTokens += u.PromptTokens
+ s.usage.CompletionTokens += u.CompletionTokens
+ s.usage.TotalTokens += u.TotalTokens
+ s.usage.CacheHitTokens += u.CacheHitTokens
+ s.usage.CacheMissTokens += u.CacheMissTokens
+ s.usage.ReasoningTokens += u.ReasoningTokens
+ s.usage.Estimated = s.usage.Estimated || u.Estimated
+}
+
+func (s *usageSink) snapshot() (provider.Usage, int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.usage, s.requests
+}
+
+// promptTokenSuffix renders what a run cost only when something reported it. A
+// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
+// measured zero rather than as no measurement.
+func promptTokenSuffix(m contextMetrics) string {
+ if m.UsageReportedRequests == 0 {
+ return ""
+ }
+ return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
+}
diff --git a/benchmarks/context-retrieval/usage_test.go b/benchmarks/context-retrieval/usage_test.go
new file mode 100644
index 0000000000..442aacbb93
--- /dev/null
+++ b/benchmarks/context-retrieval/usage_test.go
@@ -0,0 +1,54 @@
+package main
+
+import (
+ "testing"
+
+ "reasonix/internal/contract/event"
+ "reasonix/internal/contract/provider"
+)
+
+// The cost a run reports must be what the agent reported, summed across every
+// completion it billed — including one that merged attempts and one that could
+// only estimate. Any other event kind is not a cost.
+func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
+ var sink usageSink
+ sink.Emit(event.Event{}) // a kind that is not Usage
+ sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
+ PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
+ }})
+ sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
+ PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
+ }})
+ sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
+
+ usage, requests := sink.snapshot()
+ if requests != 3 {
+ t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
+ }
+ if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
+ t.Fatalf("accumulated usage = %+v", usage)
+ }
+ if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
+ t.Fatalf("cache split = %+v", usage)
+ }
+ if !usage.Estimated {
+ t.Fatal("Estimated was dropped when one completion reported an estimate")
+ }
+}
+
+// A dry run must not claim a cost it never paid, in the JSON or in the line a
+// person reads: an empty suffix, not "prompt-tok=0".
+func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
+ var sink usageSink
+ sink.Emit(event.Event{})
+ usage, requests := sink.snapshot()
+ if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
+ t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
+ }
+ if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
+ t.Fatalf("a run with no reported usage still printed %q", suffix)
+ }
+ if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
+ t.Fatalf("a reported cost printed %q", suffix)
+ }
+}
diff --git a/cmd/e2ebench/corpus_policy_test.go b/cmd/e2ebench/corpus_policy_test.go
new file mode 100644
index 0000000000..8d34b25de6
--- /dev/null
+++ b/cmd/e2ebench/corpus_policy_test.go
@@ -0,0 +1,534 @@
+package main
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/BurntSushi/toml"
+
+ fileencoding "reasonix/internal/base/fileutil/encoding"
+)
+
+// benchmarksRoot holds every committed task corpus. Validation discovers the
+// corpora under it, so a corpus that never reaches the registry fails the check
+// instead of staying outside every guard silently.
+const benchmarksRoot = "../../benchmarks"
+
+// authoringRule names one of the authoring guards a corpus answers to.
+type authoringRule string
+
+const (
+ rulePristineSeed authoringRule = "pristine-seed"
+ ruleReference authoringRule = "reference-solution"
+ ruleStopPolicy authoringRule = "stop-policy"
+)
+
+// allRules is the validator's closed vocabulary: a registry key outside it is an
+// unknown rule, not a silently ignored one.
+var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
+
+// ruleMode records how a rule applies to one corpus. There is no zero value that
+// means "not listed": omission is not a mode, so a forgotten corpus reads as an
+// error rather than as an exemption.
+type ruleMode string
+
+const (
+ // ruleEnforce applies the whole rule.
+ ruleEnforce ruleMode = "enforce"
+ // ruleEnforceWhenPresent applies only the half that tests what a corpus
+ // ships: the artefact is optional, but a shipped one must still hold up.
+ // Reference rule only, and it needs the reason the corpus is allowed to
+ // omit solutions.
+ ruleEnforceWhenPresent ruleMode = "enforce-when-present"
+ // ruleExempt keeps the corpus outside the rule, for the recorded reason.
+ ruleExempt ruleMode = "exempt"
+)
+
+type ruleDecision struct {
+ Mode ruleMode
+ Reason string
+}
+
+type corpusPolicy struct {
+ Dir string
+ Rules map[authoringRule]ruleDecision
+}
+
+// corpusPolicies is the single registry the authoring guards read. Every
+// committed corpus appears once, and every corpus/rule pair carries an explicit
+// decision, including the pairs that are deliberately outside a rule.
+func corpusPolicies() []corpusPolicy {
+ return []corpusPolicy{
+ {
+ Dir: corpusDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforceWhenPresent,
+ Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: verificationStressDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: trainCorpusDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: memorybenchDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleExempt,
+ Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: fanoutWidthDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: upstreamEdgeDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ {
+ Dir: projectCheckDir,
+ Rules: map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ },
+ },
+ }
+}
+
+// policiesRunning selects the corpora whose decision for a rule runs that rule,
+// which is what the authoring tests iterate over.
+func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
+ var out []corpusPolicy
+ for _, p := range policies {
+ switch p.Rules[rule].Mode {
+ case ruleEnforce, ruleEnforceWhenPresent:
+ out = append(out, p)
+ }
+ }
+ return out
+}
+
+// committedCorpora lists the directories under root that carry tasks/.
+func committedCorpora(root string) ([]string, error) {
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil, err
+ }
+ var out []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ continue
+ }
+ if dirExists(filepath.Join(root, e.Name(), "tasks")) {
+ out = append(out, e.Name())
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// validateCorpusPolicies checks a registry against the corpora committed under
+// root and returns one actionable error per problem. Tests supply their own
+// root, so the discovery rules can be exercised without committing fake
+// corpora into benchmarks/.
+func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
+ var errs []error
+ committed, err := committedCorpora(root)
+ if err != nil {
+ return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
+ }
+ registered := map[string]bool{}
+ for _, p := range policies {
+ name := filepath.Base(p.Dir)
+ if registered[name] {
+ errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
+ continue
+ }
+ registered[name] = true
+ // Identity is the path under this root, not the directory's name: a
+ // registry entry pointing anywhere else would have the guards check a
+ // corpus that only shares a name with the one committed here.
+ want := filepath.Clean(filepath.Join(root, name))
+ if got := filepath.Clean(p.Dir); got != want {
+ errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
+ }
+ if !dirExists(filepath.Join(p.Dir, "tasks")) {
+ errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
+ }
+ for rule := range p.Rules {
+ if !slices.Contains(allRules, rule) {
+ errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
+ }
+ }
+ for _, rule := range allRules {
+ decision, ok := p.Rules[rule]
+ if !ok {
+ errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
+ continue
+ }
+ switch decision.Mode {
+ case ruleEnforce:
+ // The full rule applies; no reason is needed or read.
+ case ruleEnforceWhenPresent:
+ if rule != ruleReference {
+ errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
+ name, decision.Mode, rule, ruleReference))
+ }
+ if strings.TrimSpace(decision.Reason) == "" {
+ errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
+ name, decision.Mode, rule))
+ }
+ case ruleExempt:
+ if strings.TrimSpace(decision.Reason) == "" {
+ errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
+ }
+ default:
+ errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
+ }
+ }
+ }
+ for _, name := range committed {
+ if !registered[name] {
+ errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
+ }
+ }
+ sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
+ return errs
+}
+
+// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
+// key distinct from a declared zero.
+type taskBounds struct {
+ MaxSteps *int `toml:"max_steps"`
+ TimeoutSec *int `toml:"timeout_sec"`
+}
+
+// stopPolicyViolations reports the tasks in one corpus that break the stop
+// rule. It reads each task.toml: the rule is about what a task declares, and a
+// default is not a declaration.
+func stopPolicyViolations(dir string) ([]string, error) {
+ tasksDir := filepath.Join(dir, "tasks")
+ entries, err := os.ReadDir(tasksDir)
+ if err != nil {
+ return nil, err
+ }
+ var out []string
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ id := entry.Name()
+ data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", id, err)
+ }
+ var bounds taskBounds
+ if _, err := toml.Decode(string(data), &bounds); err != nil {
+ return nil, fmt.Errorf("%s: %w", id, err)
+ }
+ if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
+ out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
+ id, *bounds.MaxSteps))
+ }
+ switch {
+ case bounds.TimeoutSec == nil:
+ out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
+ case *bounds.TimeoutSec <= 0:
+ out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
+ }
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// The registry must describe every committed corpus, and every corpus/rule pair
+// in it must be a decision the validator understands.
+func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
+ for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
+ t.Error(err)
+ }
+ committed, err := committedCorpora(benchmarksRoot)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(committed) == 0 {
+ t.Fatal("no committed task corpora found; the benchmark suites are missing")
+ }
+ if registered := len(corpusPolicies()); registered != len(committed) {
+ t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
+ }
+}
+
+// The registry holds one decision per corpus and rule, so the enforcement sets
+// cannot drift apart silently.
+func TestCorpusPolicyEnforcementSets(t *testing.T) {
+ policies := corpusPolicies()
+ for _, c := range []struct {
+ rule authoringRule
+ want int
+ exempt []string
+ }{
+ {rulePristineSeed, 7, nil},
+ {ruleReference, 6, []string{memorybenchDir}},
+ {ruleStopPolicy, 7, nil},
+ } {
+ running := policiesRunning(policies, c.rule)
+ if len(running) != c.want {
+ t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
+ }
+ for _, dir := range c.exempt {
+ for _, p := range running {
+ if p.Dir == dir {
+ t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
+ }
+ }
+ found := false
+ for _, p := range policies {
+ if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
+ found = true
+ }
+ }
+ if !found {
+ t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
+ }
+ }
+ }
+}
+
+// tempCorpus lays a minimal corpus under root and returns its path, so negative
+// cases run in a temporary universe instead of as fake corpora in benchmarks/.
+func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
+ t.Helper()
+ dir := filepath.Join(root, name)
+ taskDir := filepath.Join(dir, "tasks", task)
+ if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if taskToml != "" {
+ if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return dir
+}
+
+func fullDecisions() map[authoringRule]ruleDecision {
+ return map[authoringRule]ruleDecision{
+ rulePristineSeed: {Mode: ruleEnforce},
+ ruleReference: {Mode: ruleEnforce},
+ ruleStopPolicy: {Mode: ruleEnforce},
+ }
+}
+
+// Every way a registry can fall out of step with the tree is reported against
+// the corpus and rule it concerns.
+func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
+ universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
+ t.Helper()
+ root := t.TempDir()
+ var policies []corpusPolicy
+ for _, name := range corpora {
+ dir := tempCorpus(t, root, name, "t-"+name, "")
+ policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
+ }
+ return root, policies
+ }
+
+ cases := []struct {
+ name string
+ universe func(t *testing.T) (string, []corpusPolicy)
+ want string
+ }{
+ {
+ name: "unregistered corpus",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ tempCorpus(t, root, "forgotten", "t-forgotten", "")
+ return root, policies
+ },
+ want: "forgotten",
+ },
+ {
+ name: "registry entry whose tasks/ is gone",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
+ t.Fatal(err)
+ }
+ return root, policies
+ },
+ want: "no tasks/ directory",
+ },
+ {
+ name: "corpus with no decision for a rule",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ delete(policies[0].Rules, ruleStopPolicy)
+ return root, policies
+ },
+ want: string(ruleStopPolicy) + "\"",
+ },
+ {
+ name: "exemption without a reason",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
+ return root, policies
+ },
+ want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
+ },
+ {
+ name: "enforce-when-present without a reason",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
+ return root, policies
+ },
+ want: "without the reason that allows it",
+ },
+ {
+ name: "unknown mode",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
+ return root, policies
+ },
+ want: "unknown mode",
+ },
+ {
+ name: "a registry entry that shadows the root's corpus by name",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
+ policies[0].Dir = elsewhere
+ return root, policies
+ },
+ want: "but this root's corpus is",
+ },
+ {
+ name: "unknown rule",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
+ return root, policies
+ },
+ want: "unknown rule",
+ },
+ {
+ name: "enforce-when-present outside the reference rule",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ root, policies := universe(t, "registered")
+ policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
+ return root, policies
+ },
+ want: "only applies to",
+ },
+ {
+ name: "a fully declared universe is accepted",
+ universe: func(t *testing.T) (string, []corpusPolicy) {
+ return universe(t, "one", "two")
+ },
+ want: "",
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ root, policies := c.universe(t)
+ errs := validateCorpusPolicies(root, policies)
+ if c.want == "" {
+ if len(errs) != 0 {
+ t.Fatalf("valid registry reported %v", errs)
+ }
+ return
+ }
+ if len(errs) == 0 {
+ t.Fatalf("no error reported, want one mentioning %q", c.want)
+ }
+ var jb strings.Builder
+ for _, err := range errs {
+ jb.WriteString(err.Error() + "\n")
+ }
+ joined := jb.String()
+ if !strings.Contains(joined, c.want) {
+ t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
+ }
+ })
+ }
+}
+
+// The stop rule covers every registered corpus, so a cap anywhere is a failure
+// rather than an unread declaration.
+func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
+ for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
+ if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
+ t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
+ filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
+ }
+ }
+
+ root := t.TempDir()
+ for _, c := range []struct {
+ name string
+ corpus string
+ toml string
+ want []string
+ }{
+ {"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
+ {"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
+ {"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
+ {"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
+ {"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
+ {"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
+ } {
+ t.Run(c.name, func(t *testing.T) {
+ dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
+ violations, err := stopPolicyViolations(dir)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(c.want) == 0 {
+ if len(violations) != 0 {
+ t.Fatalf("accepted task reported %v", violations)
+ }
+ return
+ }
+ if len(violations) == 0 {
+ t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
+ }
+ if !strings.Contains(violations[0], c.want[0]) {
+ t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
+ }
+ })
+ }
+}
diff --git a/cmd/e2ebench/corpus_test.go b/cmd/e2ebench/corpus_test.go
index 4f05fa302b..b3eb7f1e33 100644
--- a/cmd/e2ebench/corpus_test.go
+++ b/cmd/e2ebench/corpus_test.go
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
- for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
+ for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
+ dir := policy.Dir
if !dirExists(dir) {
continue
}
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
- for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
+ for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
+ dir := policy.Dir
+ decision := policy.Rules[ruleReference]
if !dirExists(dir) {
continue
}
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
- if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
- t.Fatal("no solution/: a corpus task must prove its grader can pass")
+ if decision.Mode == ruleEnforceWhenPresent {
+ t.Skip("no solution/: " + decision.Reason)
}
- t.Skip("no solution/: the e2e suite does not commit reference solutions")
+ t.Fatal("no solution/: a corpus task must prove its grader can pass")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// A task that caps its own tool rounds measures the cap, not the agent: the
// product ships unbounded (agent.Run calls bounding the loop the host's call),
-// so a capped corpus grades a configuration no default user runs. Only two
-// committed tasks ever reached a cap, both no-solution ones, where the host's
-// "summarise your progress" preempts the sentence the honesty score exists for.
+// so committed benchmark tasks must not set one. With no cap the wall clock is
+// the only bound left, so every task must declare a positive timeout_sec.
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
- for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
- tasks, err := loadTasks(dir)
+ for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
+ dir := policy.Dir
+ violations, err := stopPolicyViolations(dir)
if err != nil {
t.Fatalf("load %s: %v", dir, err)
}
- for _, task := range tasks {
- if task.MaxSteps > 0 {
- t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
- task.ID, task.MaxSteps)
- }
- // Without a round cap the wall clock is the only backstop left, so
- // every task needs one or a stuck run has nothing to end it.
- if task.TimeoutSec <= 0 {
- t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
- }
+ for _, violation := range violations {
+ t.Errorf("%s: %s", filepath.Base(dir), violation)
}
}
}
diff --git a/cmd/e2ebench/diff.go b/cmd/e2ebench/diff.go
index 81f1846a2d..d49bc88a9f 100644
--- a/cmd/e2ebench/diff.go
+++ b/cmd/e2ebench/diff.go
@@ -108,21 +108,27 @@ func runOnce(o diffOpts, srcFiles, pkgs []string, prompt string) diffReport {
var pins []pinResult
var mut mutationResult
+ var analysisErr error
covered, coverTotal := 0, 0
if len(refs) > 0 && testsPass {
covered, coverTotal = changedLineCoverage(o.repo, o.base, pkgs, srcFiles)
- pins = differentialPerTest(o.repo, o.base, srcFiles, refs)
- mut = runMutation(o.repo, o.base, srcFiles, refs)
+ pins, analysisErr = differentialPerTest(o.repo, o.base, srcFiles, refs)
+ // A failed restore leaves the tree unreliable, so the mutation sweep that
+ // would edit those files does not start.
+ if analysisErr == nil {
+ mut, analysisErr = runMutation(o.repo, o.base, srcFiles, refs)
+ }
}
buildOK, buildOut := goBuildAll(o.repo)
- passed := len(refs) > 0 && testsPass && buildOK && countPins(pins) > 0
+ passed := len(refs) > 0 && testsPass && buildOK && analysisErr == nil && countPins(pins) > 0
return diffReport{
srcFiles: srcFiles, pkgs: pkgs, addedTestLines: countAdded(testDiff),
newTests: refs, sourceTouched: sourceTouched, testsPass: testsPass,
pins: pins, mut: mut, covered: covered, coverTotal: coverTotal,
buildOK: buildOK, buildOut: buildOut, failing: failingTestNames(testOut),
- passed: passed, profile: o.profile, m: m, runErr: runErr, testOut: testOut, testDiff: testDiff,
+ passed: passed, profile: o.profile, m: m, runErr: runErr, analysisErr: analysisErr,
+ testOut: testOut, testDiff: testDiff,
}
}
@@ -202,6 +208,7 @@ type diffReport struct {
attempt, attempts int
m runMetrics
runErr error
+ analysisErr error
testOut string
testDiff string
}
@@ -283,6 +290,9 @@ func renderDiff(r diffReport) string {
if r.runErr != nil {
fmt.Fprintf(&b, "\nagent run note: %v\n", r.runErr)
}
+ if line := analysisFailureLine(r); line != "" {
+ b.WriteString(line)
+ }
fmt.Fprintf(&b, "\nPass = the agent added ≥1 test, the affected packages are green, AND ≥1 new test fails when the PR's source is reverted. \"By assertion\" pins are strong (they check changed behavior); \"by compile only\" pins just need a PR-added symbol — and since Go compiles per package, one compile-coupled test marks every test in its package that way. Mutation is the behavioral signal for additive PRs: each changed function's return is replaced with zero values and the new tests are re-run; \"caught\" means a test asserts that output, \"survived\" means it doesn't. Read the generated tests above to judge the rest.\n")
return b.String()
}
@@ -326,13 +336,14 @@ func pinCell(p pinResult) string {
// differentialPerTest reverts the PR's changed source to base (deleting files
// new in the PR), runs each generated test on its own against the old code, and
// restores the source. A test that fails on the old code pins the change.
-func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) []pinResult {
+func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) (out []pinResult, err error) {
for _, f := range srcFiles {
- if err := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); err != nil {
+ if cerr := exec.Command("git", "-C", repo, "checkout", base, "--", f).Run(); cerr != nil {
_ = os.Remove(filepath.Join(repo, filepath.FromSlash(f)))
}
}
- // Restore source even on panic; a tree left on `base` would mask the PR for later steps.
+ // Restore source on the way out; a tree left on `base` would mask the PR for
+ // later steps, so a failed restore is this analysis failing, not a note.
restored := false
defer func() {
if restored {
@@ -341,9 +352,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
for _, f := range srcFiles {
_ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
}
+ if terr := treeMatches(repo, "HEAD", srcFiles); terr != nil {
+ err = terr
+ }
}()
+ if err := treeMatches(repo, base, srcFiles); err != nil {
+ return nil, err
+ }
- out := make([]pinResult, 0, len(refs))
+ out = make([]pinResult, 0, len(refs))
for _, r := range refs {
cmd := exec.Command("go", "test", "-run", "^"+r.name+"$", r.pkg)
cmd.Dir = repo
@@ -356,10 +373,15 @@ func differentialPerTest(repo, base string, srcFiles []string, refs []testRef) [
})
}
for _, f := range srcFiles {
- _ = exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run()
+ if cerr := exec.Command("git", "-C", repo, "checkout", "HEAD", "--", f).Run(); cerr != nil {
+ return nil, fmt.Errorf("restore %s: %w", f, cerr)
+ }
+ }
+ if err := treeMatches(repo, "HEAD", srcFiles); err != nil {
+ return nil, err
}
restored = true
- return out
+ return out, nil
}
// changedLineCoverage runs the affected packages with a coverage profile and
@@ -659,3 +681,16 @@ func passFail(ok bool) string {
}
return "fail"
}
+
+// analysisFailureLine says which figures a failed analysis leaves absent. A pin
+// table that was already produced still stands, so claiming both are missing
+// would be wrong whenever only the mutation sweep failed.
+func analysisFailureLine(r diffReport) string {
+ if r.analysisErr == nil {
+ return ""
+ }
+ if len(r.pins) > 0 {
+ return fmt.Sprintf("\n**Analysis did not finish: %v.** The mutation figures are absent, not zero; the pin table above still stands.\n", r.analysisErr)
+ }
+ return fmt.Sprintf("\n**Analysis did not finish: %v.** Pin and mutation figures are absent, not zero.\n", r.analysisErr)
+}
diff --git a/cmd/e2ebench/main.go b/cmd/e2ebench/main.go
index 37ba32cfb6..bdcfd4f8d2 100644
--- a/cmd/e2ebench/main.go
+++ b/cmd/e2ebench/main.go
@@ -285,7 +285,7 @@ func main() {
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
tapeRecord, tapeReplay := registerTapeFlags()
- trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
+ trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
flag.Parse()
*trajDir = absTrajectoryDir(*trajDir)
if *trials > 1 && *attempts > 1 {
diff --git a/cmd/e2ebench/meter.go b/cmd/e2ebench/meter.go
index 53a59d7177..6d322731c8 100644
--- a/cmd/e2ebench/meter.go
+++ b/cmd/e2ebench/meter.go
@@ -53,6 +53,10 @@ type meterUsage struct {
DivergedAt int `json:"diverged_at,omitempty"`
Divergence string `json:"divergence,omitempty"`
TapeMissing int `json:"tape_missing,omitempty"`
+ // TapeIncomplete counts recorded exchanges that could not be written, so a
+ // recording that cannot be replayed is reported rather than assumed.
+ TapeIncomplete int `json:"tape_incomplete,omitempty"`
+ TapeError string `json:"tape_error,omitempty"`
}
// faultScript decides which requests fail. Absolute indices pin a failure to
@@ -220,7 +224,16 @@ func (m *meter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
src := io.Reader(resp.Body)
if m.tape != nil {
src = io.TeeReader(resp.Body, &captured)
- defer func() { m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()) }()
+ defer func() {
+ if err := m.tape.save(index, body, resp.StatusCode, resp.Header.Get("Content-Type"), captured.Bytes()); err != nil {
+ m.mu.Lock()
+ m.TapeIncomplete++
+ if m.TapeError == "" {
+ m.TapeError = err.Error()
+ }
+ m.mu.Unlock()
+ }
+ }()
}
if strings.Contains(resp.Header.Get("Content-Type"), "event-stream") {
m.pipeStream(w, src)
diff --git a/cmd/e2ebench/meterrun.go b/cmd/e2ebench/meterrun.go
index 5641d543f9..7b644edb5f 100644
--- a/cmd/e2ebench/meterrun.go
+++ b/cmd/e2ebench/meterrun.go
@@ -47,12 +47,14 @@ func (rm runMeter) close() {
}
}
-// record attaches what the proxy observed. It stays separate from close so the
-// snapshot is taken while the run's numbers are still being assembled.
+// record attaches what the proxy observed. It stops the meter first, because a
+// handler's deferred write can land after the last response is read and stopping
+// is what waits for those handlers; a snapshot taken before that misses them.
func (rm runMeter) record(r *result) {
if rm.m == nil {
return
}
+ rm.close()
observed := rm.m.snapshot()
r.Meter = &observed
}
diff --git a/cmd/e2ebench/mutation.go b/cmd/e2ebench/mutation.go
index 0d0847f7c1..076f2ecf9e 100644
--- a/cmd/e2ebench/mutation.go
+++ b/cmd/e2ebench/mutation.go
@@ -25,14 +25,13 @@ type mutationResult struct {
// that package, and records whether they catch the mutation. A caught mutant
// means a test actually asserts that function's output; a survivor means the new
// tests don't check it.
-func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationResult {
+func runMutation(repo, base string, srcFiles []string, refs []testRef) (res mutationResult, err error) {
changed := changedLineSet(repo, base, srcFiles)
byPkg := map[string][]string{}
for _, r := range refs {
byPkg[r.pkg] = append(byPkg[r.pkg], r.name)
}
- var res mutationResult
for _, file := range srcFiles {
if res.total >= maxMutants {
break
@@ -63,22 +62,28 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
lb := fset.Position(fd.Body.Lbrace).Offset
rb := fset.Position(fd.Body.Rbrace).Offset
mutated := src[:lb] + mutantBody(fset, fd.Type.Results) + src[rb+1:]
- if os.WriteFile(abs, []byte(mutated), 0o644) != nil {
+ if err := writeFile(abs, []byte(mutated), 0o644); err != nil {
res.total--
continue
}
cmd := exec.Command("go", "test", "-run", runRe, pkg)
cmd.Dir = repo
cmd.WaitDelay = 2 * time.Minute // bound the wait for a mutant that wedges a test
- // Restore source even on panic; a file left mutated would corrupt the next mutant.
+ // Restore source on the way out; a file left mutated would corrupt the
+ // next mutant, so a failed restore is the analysis failing, not a note.
restored := false
defer func() {
- if !restored {
- _ = os.WriteFile(abs, srcB, 0o644)
+ if restored {
+ return
+ }
+ if rerr := restoreSource(abs, srcB); rerr != nil {
+ res, err = mutationResult{}, rerr
}
}()
caught := cmd.Run() != nil
- _ = os.WriteFile(abs, srcB, 0o644)
+ if rerr := restoreSource(abs, srcB); rerr != nil {
+ return mutationResult{}, rerr
+ }
restored = true
if caught {
res.caught++
@@ -87,7 +92,7 @@ func runMutation(repo, base string, srcFiles []string, refs []testRef) mutationR
}
}
}
- return res
+ return res, nil
}
// changedFuncs returns the funcs in f whose line range overlaps a changed line.
diff --git a/cmd/e2ebench/report.go b/cmd/e2ebench/report.go
index e7d1f6c8b5..6d839902e9 100644
--- a/cmd/e2ebench/report.go
+++ b/cmd/e2ebench/report.go
@@ -202,6 +202,7 @@ func renderBody(results []result) string {
b.WriteString(renderAuxiliarySpend(s.bySource, s.currency))
b.WriteString(renderMeterAccounting(results))
b.WriteString(renderTapeReplay(results))
+ b.WriteString(renderTapeRecord(results))
b.WriteString(renderFaultRecovery(results))
b.WriteString(renderTimeAttribution(results))
b.WriteString(renderSolveProfiles(results))
diff --git a/cmd/e2ebench/reproduce.go b/cmd/e2ebench/reproduce.go
index c6a71a6dce..fe99c8fa84 100644
--- a/cmd/e2ebench/reproduce.go
+++ b/cmd/e2ebench/reproduce.go
@@ -7,10 +7,11 @@ import (
"strings"
)
-// reproductionSection says whether two reports are one capability version on
-// one corpus, and whether their rates differ by more than chance would. A
-// difference inside the interval is not a finding; one outside it is only a
-// finding about versions if the version section says the two are one version.
+// reproductionSection says whether two reports share a capability set and a
+// task prompt set, and whether their rates differ by more than chance would.
+// Those are the only identities compared: the capability hash and task id to
+// prompt. A difference inside the interval is not a finding; one outside it is
+// a finding about a version only if the same capability set says so.
func reproductionSection(pathA, pathB string) string {
a, errA := loadResults(pathA)
b, errB := loadResults(pathB)
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
}
var s strings.Builder
s.WriteString("\n### Reproduction\n\n")
- fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
- fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
+ version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
+ fmt.Fprintf(&s, "- capability set: %s\n", version.text)
+ fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
+ s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
allWithin := true
for _, m := range []struct {
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
}
- if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
- s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
+ if allWithin && version.same && corpus.same {
+ s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
}
return s.String()
}
@@ -56,7 +59,14 @@ func counted(results []result) []result {
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
}
-func versionVerdict(a, b []result) string {
+// verdict is one identity comparison: same says the two sides matched on what
+// was compared, text says what that was.
+type verdict struct {
+ same bool
+ text string
+}
+
+func versionVerdict(a, b []result) verdict {
setOf := func(rs []result) []string {
var out []string
for _, r := range counted(rs) {
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
sa, sb := setOf(a), setOf(b)
switch {
case len(sa) == 0 || len(sb) == 0:
- return "unknown (a report records no capability set)"
+ return verdict{text: "unknown (a report records no capability set)"}
case len(sa) > 1 || len(sb) > 1:
- return "mixed (a report spans more than one capability set)"
+ return verdict{text: "mixed (a report spans more than one capability set)"}
case sa[0] == sb[0]:
- return "one version (" + short(sa[0]) + ")"
+ return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
default:
- return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
+ return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
}
}
-func corpusVerdict(a, b []result) string {
+func corpusVerdict(a, b []result) verdict {
prompts := func(rs []result) map[string]string {
out := map[string]string{}
for _, r := range counted(rs) {
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
}
}
if differ == 0 {
- return fmt.Sprintf("same %d tasks", len(pa))
+ return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
}
- return fmt.Sprintf("%d tasks differ between the reports", differ)
+ return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
}
func passRate(rs []result) (int, int) {
diff --git a/cmd/e2ebench/reproduce_test.go b/cmd/e2ebench/reproduce_test.go
index 034ac2ae1a..81815fe643 100644
--- a/cmd/e2ebench/reproduce_test.go
+++ b/cmd/e2ebench/reproduce_test.go
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
got := reproductionSection(a, same)
- for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
+ for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
if !strings.Contains(got, want) {
t.Fatalf("same version report missing %q:\n%s", want, got)
}
}
+ for _, claim := range []string{"One version", "one corpus"} {
+ if strings.Contains(got, claim) {
+ t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
+ }
+ }
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
t.Fatalf("a 70pp drop read as noise:\n%s", got)
}
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
- if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
+ if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
t.Fatalf("two versions read as a reproduction:\n%s", got)
}
}
diff --git a/cmd/e2ebench/restore.go b/cmd/e2ebench/restore.go
new file mode 100644
index 0000000000..dc36242b20
--- /dev/null
+++ b/cmd/e2ebench/restore.go
@@ -0,0 +1,46 @@
+package main
+
+import (
+ "bytes"
+ "fmt"
+ "os"
+ "os/exec"
+ "strings"
+)
+
+// Restoring the source is I/O like any other step, and an analysis must not
+// report a verdict about a tree it failed to restore. These helpers make that
+// failure loud, and are variables so a test can inject one.
+
+var (
+ writeFile = os.WriteFile
+ readFile = os.ReadFile
+ // treeMatches is a variable so a test can inject the mismatch a restore guards.
+ treeMatches = treeMatchesTree
+)
+
+// restoreSource writes the original bytes back and proves they are back.
+func restoreSource(abs string, src []byte) error {
+ if err := writeFile(abs, src, 0o644); err != nil {
+ return fmt.Errorf("restore %s: %w", abs, err)
+ }
+ got, err := readFile(abs)
+ if err != nil {
+ return fmt.Errorf("verify %s: %w", abs, err)
+ }
+ if !bytes.Equal(got, src) {
+ return fmt.Errorf("verify %s: %d bytes on disk, %d expected", abs, len(got), len(src))
+ }
+ return nil
+}
+
+// treeMatches checks files against a revision. A checkout that exited zero is
+// not evidence the tree moved; the tree is.
+func treeMatchesTree(repo, rev string, files []string) error {
+ args := append([]string{"-C", repo, "diff", "--exit-code", rev, "--"}, files...)
+ out, err := exec.Command("git", args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s does not match %s after restore: %w: %s", repo, rev, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
diff --git a/cmd/e2ebench/restore_test.go b/cmd/e2ebench/restore_test.go
new file mode 100644
index 0000000000..adbbc458da
--- /dev/null
+++ b/cmd/e2ebench/restore_test.go
@@ -0,0 +1,261 @@
+package main
+
+import (
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func withFakeIO(t *testing.T, w func(string, []byte, os.FileMode) error, r func(string) ([]byte, error)) {
+ t.Helper()
+ ow, or := writeFile, readFile
+ writeFile, readFile = w, r
+ t.Cleanup(func() { writeFile, readFile = ow, or })
+}
+
+// A restore that cannot write is not a restore.
+func TestRestoreSourceFailsWhenTheWriteFails(t *testing.T) {
+ withFakeIO(t, func(string, []byte, os.FileMode) error { return errors.New("disk full") }, os.ReadFile)
+ err := restoreSource("/tmp/unused", []byte("original"))
+ if err == nil || !strings.Contains(err.Error(), "disk full") {
+ t.Fatalf("a failed restore write was not reported: %v", err)
+ }
+}
+
+// A restore that wrote something else is not a restore either.
+func TestRestoreSourceFailsWhenTheBytesDoNotComeBack(t *testing.T) {
+ withFakeIO(t,
+ func(string, []byte, os.FileMode) error { return nil },
+ func(string) ([]byte, error) { return []byte("truncated"), nil })
+ err := restoreSource("/tmp/unused", []byte("original"))
+ if err == nil || !strings.Contains(err.Error(), "bytes on disk") {
+ t.Fatalf("a silent truncation passed verification: %v", err)
+ }
+}
+
+// The real filesystem is the other witness: writing over a directory fails.
+func TestRestoreSourceFailsOnARealWriteError(t *testing.T) {
+ if err := restoreSource(t.TempDir(), []byte("original")); err == nil {
+ t.Fatal("restoring onto a directory reported success")
+ }
+}
+
+func TestTreeMatchesSeesADirtyTree(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git unavailable")
+ }
+ repo := t.TempDir()
+ git := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+ git("init", "-q")
+ file := "a.go"
+ if err := os.WriteFile(filepath.Join(repo, file), []byte("package a\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ git("add", file)
+ git("commit", "-qm", "init")
+
+ if err := treeMatches(repo, "HEAD", []string{file}); err != nil {
+ t.Fatalf("a clean tree was reported dirty: %v", err)
+ }
+ if err := os.WriteFile(filepath.Join(repo, file), []byte("package a // mutated\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := treeMatches(repo, "HEAD", []string{file}); err == nil {
+ t.Fatal("a mutated file passed the tree check")
+ }
+}
+
+// The mutation sweep must not hand back a caught/survivor verdict when the
+// source could not be restored: the numbers would describe a tree that no
+// longer holds the PR's code.
+func TestRunMutationFailsWhenTheRestoreFails(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git unavailable")
+ }
+ if _, err := exec.LookPath("go"); err != nil {
+ t.Skip("go unavailable")
+ }
+ repo := t.TempDir()
+ git := func(args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ return strings.TrimSpace(string(out))
+ }
+ mustWrite := func(rel, body string) {
+ t.Helper()
+ abs := filepath.Join(repo, rel)
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ mustWrite("go.mod", "module mutationfixture\n\ngo 1.24\n")
+ mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
+ mustWrite("pkg/mut_test.go", "package pkg\n\nimport \"testing\"\n\nfunc TestMut(t *testing.T) {\n\tif Answer() != 2 {\n\t\tt.Fatalf(\"got %d\", Answer())\n\t}\n}\n")
+ git("init", "-q")
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ base := git("rev-parse", "HEAD")
+
+ mustWrite("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
+ git("add", "-A")
+ git("commit", "-qm", "change")
+
+ // The first write mutates the source, the second restores it. Failing the
+ // second one is exactly the case the sweep used to swallow.
+ calls := 0
+ withFakeIO(t, func(path string, body []byte, mode os.FileMode) error {
+ calls++
+ if calls == 2 {
+ return errors.New("disk full")
+ }
+ return os.WriteFile(path, body, mode)
+ }, os.ReadFile)
+
+ res, err := runMutation(repo, base, []string{"pkg/mut.go"}, []testRef{{name: "TestMut", pkg: "./pkg"}})
+ if err == nil {
+ t.Fatalf("a failed restore still produced a verdict: caught=%d total=%d survivors=%v", res.caught, res.total, res.survivors)
+ }
+ if !strings.Contains(err.Error(), "disk full") {
+ t.Fatalf("error does not name the failing restore: %v", err)
+ }
+}
+
+// The same guarantee on the differential side: a restore that cannot put a file
+// back must stop the analysis instead of reporting pins from a mixed tree.
+func TestDifferentialPerTestFailsWhenRestoreFails(t *testing.T) {
+ if _, err := exec.LookPath("git"); err != nil {
+ t.Skip("git unavailable")
+ }
+ repo := t.TempDir()
+ git := func(args ...string) string {
+ t.Helper()
+ cmd := exec.Command("git", args...)
+ cmd.Dir = repo
+ cmd.Env = append(os.Environ(),
+ "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.com",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.com")
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ return strings.TrimSpace(string(out))
+ }
+ write := func(rel, body string) {
+ t.Helper()
+ abs := filepath.Join(repo, rel)
+ if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(abs, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("go.mod", "module difffixture\n\ngo 1.24\n")
+ write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 1\n}\n")
+ git("init", "-q")
+ git("add", "-A")
+ git("commit", "-qm", "base")
+ base := git("rev-parse", "HEAD")
+
+ write("pkg/mut.go", "package pkg\n\nfunc Answer() int {\n\treturn 2\n}\n")
+ git("add", "-A")
+ git("commit", "-qm", "change")
+
+ // pkg/missing.go exists in neither revision, so `git checkout HEAD --` cannot
+ // put it back: the restore step fails, which must not be swallowed.
+ srcFiles := []string{"pkg/mut.go", "pkg/missing.go"}
+ if _, err := differentialPerTest(repo, base, srcFiles, []testRef{{name: "TestMut", pkg: "./pkg"}}); err == nil {
+ t.Fatal("a failed restore still produced pin results")
+ }
+}
+
+// The base side is checked before any pin runs, and that check can fail too: the
+// tree must not be left on base just because the failure happened early. The
+// defer that restores is registered before the check for exactly this path.
+func TestDifferentialPerTestRestoresWhenTheBaseCheckFails(t *testing.T) {
+ repo := t.TempDir()
+ run := func(args ...string) {
+ t.Helper()
+ cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
+ "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
+ if out, err := cmd.CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v: %s", args, err, out)
+ }
+ }
+ run("init", "-q")
+ if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run("add", "f.go")
+ run("commit", "-qm", "base")
+ base := strings.TrimSpace(gitOut(repo, "rev-parse", "HEAD"))
+ if err := os.WriteFile(filepath.Join(repo, "f.go"), []byte("head\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ run("commit", "-aqm", "head")
+
+ orig := treeMatches
+ treeMatches = func(r, rev string, files []string) error {
+ if rev == base {
+ return errors.New("injected: base side does not match")
+ }
+ return treeMatchesTree(r, rev, files)
+ }
+ t.Cleanup(func() { treeMatches = orig })
+
+ out, err := differentialPerTest(repo, base, []string{"f.go"}, nil)
+ if err == nil {
+ t.Fatalf("a failed base check reported success with %d pins", len(out))
+ }
+ got, rerr := os.ReadFile(filepath.Join(repo, "f.go"))
+ if rerr != nil {
+ t.Fatal(rerr)
+ }
+ if strings.TrimSpace(string(got)) != "head" {
+ t.Fatalf("the tree was left on base: f.go = %q, want the PR's content", strings.TrimSpace(string(got)))
+ }
+}
+
+// A pin table that was already produced still stands, so the failure line must
+// not claim both figures are missing when only the mutation sweep failed.
+func TestAnalysisFailureLineNamesWhatIsMissing(t *testing.T) {
+ only := analysisFailureLine(diffReport{analysisErr: errors.New("boom"), pins: []pinResult{{}}})
+ if !strings.Contains(only, "mutation figures are absent") || !strings.Contains(only, "pin table above still stands") {
+ t.Fatalf("with pins present the line reads %q", only)
+ }
+ if strings.Contains(only, "Pin and mutation figures are absent") {
+ t.Fatalf("with pins present the line still claims both are absent: %q", only)
+ }
+ both := analysisFailureLine(diffReport{analysisErr: errors.New("boom")})
+ if !strings.Contains(both, "Pin and mutation figures are absent") {
+ t.Fatalf("with no pins the line reads %q", both)
+ }
+ if analysisFailureLine(diffReport{}) != "" {
+ t.Fatal("a finished analysis produced a failure line")
+ }
+}
diff --git a/cmd/e2ebench/rollout.go b/cmd/e2ebench/rollout.go
index 24f560cdc4..63df9f0ec7 100644
--- a/cmd/e2ebench/rollout.go
+++ b/cmd/e2ebench/rollout.go
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
case bv == cv:
hold("rollout.same_version")
}
- if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
+ if !corpusVerdict(baseline, candidate).same {
hold("rollout.corpus_mismatch")
}
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
diff --git a/cmd/e2ebench/tape.go b/cmd/e2ebench/tape.go
index 77753cbdf1..e0275798d7 100644
--- a/cmd/e2ebench/tape.go
+++ b/cmd/e2ebench/tape.go
@@ -107,6 +107,18 @@ func (c tapeConfig) forRun(taskID string, trial int) (*tape, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
+ // This run replaces the tape. Drop the old completeness markers first, or
+ // a failure here would leave the previous request beside this run's
+ // partial response and read as a complete exchange.
+ stale, err := filepath.Glob(filepath.Join(dir, "*.request.json"))
+ if err != nil {
+ return nil, err
+ }
+ for _, path := range stale {
+ if err := os.Remove(path); err != nil {
+ return nil, err
+ }
+ }
} else if _, err := os.Stat(dir); err != nil {
return nil, fmt.Errorf("no tape for %s: %w", taskID, err)
}
@@ -122,11 +134,26 @@ func (t *tape) file(index int, suffix string) string {
return filepath.Join(t.dir, fmt.Sprintf("%04d.%s", index, suffix))
}
-func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) {
- meta, _ := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
- _ = os.WriteFile(t.file(index, "request.json"), request, 0o644)
- _ = os.WriteFile(t.file(index, "response.json"), meta, 0o644)
- _ = os.WriteFile(t.file(index, "response.body"), body, 0o644)
+// tapeWriteFile is a variable so a test can inject a write failure.
+var tapeWriteFile = os.WriteFile
+
+// save records one exchange. request.json goes last: its presence is what tells
+// a later replay the exchange is complete rather than truncated.
+func (t *tape) save(index int, request []byte, status int, contentType string, body []byte) error {
+ meta, err := json.Marshal(tapeResponse{Status: status, ContentType: contentType})
+ if err != nil {
+ return fmt.Errorf("tape %d response meta: %w", index, err)
+ }
+ if err := tapeWriteFile(t.file(index, "response.json"), meta, 0o644); err != nil {
+ return fmt.Errorf("tape %d response.json: %w", index, err)
+ }
+ if err := tapeWriteFile(t.file(index, "response.body"), body, 0o644); err != nil {
+ return fmt.Errorf("tape %d response.body: %w", index, err)
+ }
+ if err := tapeWriteFile(t.file(index, "request.json"), request, 0o644); err != nil {
+ return fmt.Errorf("tape %d request.json: %w", index, err)
+ }
+ return nil
}
// load returns the recorded answer to request index, and where the request
@@ -142,7 +169,12 @@ func (t *tape) load(index int, request []byte) (resp tapeResponse, body []byte,
if body, err = os.ReadFile(t.file(index, "response.body")); err != nil {
return resp, nil, "", err
}
- recorded, _ := os.ReadFile(t.file(index, "request.json"))
+ recorded, err := os.ReadFile(t.file(index, "request.json"))
+ if err != nil {
+ // Not a divergence: without the recorded request there is nothing to
+ // compare against, and reporting one would blame the harness.
+ return resp, nil, "", fmt.Errorf("tape %d is incomplete: %w", index, err)
+ }
return resp, body, requestDiff(recorded, request), nil
}
@@ -249,3 +281,19 @@ func renderTapeReplay(results []result) string {
}
return line + "\n\n"
}
+
+// renderTapeRecord reports recordings that did not finish, so a run whose tape
+// cannot be replayed says so instead of looking recorded.
+func renderTapeRecord(results []result) string {
+ var incomplete []string
+ for _, r := range results {
+ if r.Meter == nil || r.Meter.TapeIncomplete == 0 {
+ continue
+ }
+ incomplete = append(incomplete, fmt.Sprintf("`%s` (%d exchanges): %s", r.ID, r.Meter.TapeIncomplete, r.Meter.TapeError))
+ }
+ if len(incomplete) == 0 {
+ return ""
+ }
+ return "**Tape record incomplete**: " + strings.Join(incomplete, " · ") + "\n\n"
+}
diff --git a/cmd/e2ebench/tape_test.go b/cmd/e2ebench/tape_test.go
index 40bf89d3a5..ef27d3f0d9 100644
--- a/cmd/e2ebench/tape_test.go
+++ b/cmd/e2ebench/tape_test.go
@@ -1,11 +1,15 @@
package main
import (
+ "errors"
"io"
"net/http"
+ "os"
+ "path/filepath"
"strings"
"sync/atomic"
"testing"
+ "time"
)
const tapedStream = "data: {\"choices\":[{\"delta\":{\"content\":\"fixed\"}}]}\n\n" +
@@ -103,3 +107,179 @@ func TestRequestDiffReadsStructureBeforeBytes(t *testing.T) {
}
}
}
+
+func waitFor(t *testing.T, what string, cond func() bool) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if cond() {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatalf("timed out waiting for %s", what)
+}
+
+// A recording whose request file cannot be written is reported rather than
+// treated as a tape: request.json is what marks the exchange complete.
+func TestTapeSaveFailureIsReportedOnTheMeter(t *testing.T) {
+ root := t.TempDir()
+ orig := tapeWriteFile
+ tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
+ if strings.HasSuffix(path, "request.json") {
+ return errors.New("disk full")
+ }
+ return orig(path, body, mode)
+ }
+ t.Cleanup(func() { tapeWriteFile = orig })
+
+ var calls atomic.Int32
+ m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
+ defer stop()
+ readAll(t, post(t, base, "/chat/completions", tapedRequest))
+
+ var got meterUsage
+ waitFor(t, "the failed write to be reported", func() bool {
+ got = m.snapshot()
+ return got.TapeIncomplete > 0
+ })
+ if !strings.Contains(got.TapeError, "disk full") {
+ t.Fatalf("incomplete recording = %+v, want the write error named", got)
+ }
+ if markers, _ := filepath.Glob(filepath.Join(root, "bugfix-slug", "trial-1", "*.request.json")); len(markers) != 0 {
+ t.Fatalf("the completeness marker was written although the request write failed: %v", markers)
+ }
+ line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: &got}})
+ if !strings.Contains(line, "Tape record incomplete") || !strings.Contains(line, "bugfix-slug") {
+ t.Fatalf("report line = %q", line)
+ }
+ if renderTapeRecord(nil) != "" {
+ t.Fatal("an empty run set produced a tape line")
+ }
+}
+
+// A tape with no recorded request is incomplete, not a divergence: there is
+// nothing to compare against, and blaming the harness would be wrong.
+func TestTapeMissingRequestIsIncompleteNotADivergence(t *testing.T) {
+ root := t.TempDir()
+ var calls atomic.Int32
+ _, base, stop := tapedMeter(t, tapeRecord, root, &calls)
+ readAll(t, post(t, base, "/chat/completions", tapedRequest))
+ dir := filepath.Join(root, "bugfix-slug", "trial-1")
+ var markers []string
+ waitFor(t, "the recorded request", func() bool {
+ markers, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
+ return len(markers) > 0
+ })
+ stop()
+ if err := os.Remove(markers[0]); err != nil {
+ t.Fatal(err)
+ }
+
+ m, base, stop := tapedMeter(t, tapeReplay, root, &calls)
+ defer stop()
+ resp := post(t, base, "/chat/completions", tapedRequest)
+ readAll(t, resp)
+ got := m.snapshot()
+ if resp.StatusCode != http.StatusBadGateway || got.TapeMissing != 1 {
+ t.Fatalf("incomplete tape = %d, missing %d; want 502 and counted", resp.StatusCode, got.TapeMissing)
+ }
+ if got.DivergedAt != 0 || got.Divergence != "" {
+ t.Fatalf("an incomplete tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
+ }
+}
+
+// The run path snapshots the meter through record, which must stop the meter
+// first: stopping is what waits for the handlers whose deferred writes carry the
+// tape counters. Snapshotting before that misses the last exchange.
+func TestMeterRecordDrainsHandlersBeforeSnapshot(t *testing.T) {
+ root := t.TempDir()
+ var calls atomic.Int32
+ m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
+ defer stop()
+ readAll(t, post(t, base, "/chat/completions", tapedRequest))
+
+ var r result
+ runMeter{m: m, stop: stop}.record(&r)
+ // No waitFor: the drain inside record is what makes this observable.
+ if r.Meter == nil {
+ t.Fatal("record attached no meter")
+ }
+ if resp, err := http.Post(base+"/chat/completions", "application/json", strings.NewReader(tapedRequest)); err == nil {
+ resp.Body.Close()
+ t.Fatal("the meter was still serving after record, so its handlers were not drained")
+ }
+}
+
+// Re-recording over an existing tape must not let the previous run's
+// completeness marker make a partial new exchange look complete.
+func TestTapeReRecordClearsStaleCompletenessMarkers(t *testing.T) {
+ root := t.TempDir()
+ var calls atomic.Int32
+ _, base, stop := tapedMeter(t, tapeRecord, root, &calls)
+ readAll(t, post(t, base, "/chat/completions", tapedRequest))
+ dir := filepath.Join(root, "bugfix-slug", "trial-1")
+ waitFor(t, "the first recording", func() bool {
+ m, _ := filepath.Glob(filepath.Join(dir, "*.request.json"))
+ return len(m) > 0
+ })
+ stop()
+
+ // The second recording fails while writing the body, after the request has
+ // already been replaced in memory.
+ orig := tapeWriteFile
+ tapeWriteFile = func(path string, body []byte, mode os.FileMode) error {
+ if strings.HasSuffix(path, "response.body") {
+ return errors.New("disk full")
+ }
+ return orig(path, body, mode)
+ }
+ t.Cleanup(func() { tapeWriteFile = orig })
+
+ var calls2 atomic.Int32
+ m2, base2, stop2 := tapedMeter(t, tapeRecord, root, &calls2)
+ defer stop2()
+ readAll(t, post(t, base2, "/chat/completions", tapedRequest))
+ var r result
+ runMeter{m: m2, stop: stop2}.record(&r)
+ if markers, _ := filepath.Glob(filepath.Join(dir, "*.request.json")); len(markers) != 0 {
+ t.Fatalf("a failed re-record left completeness markers: %v", markers)
+ }
+ // Replay must call the exchange incomplete, not compare a stale request with
+ // a partial response.
+ m3, base3, stop3 := tapedMeter(t, tapeReplay, root, &calls2)
+ defer stop3()
+ resp := post(t, base3, "/chat/completions", tapedRequest)
+ readAll(t, resp)
+ if resp.StatusCode != http.StatusBadGateway {
+ t.Fatalf("replay of a partial tape = %d, want 502", resp.StatusCode)
+ }
+ if got := m3.snapshot(); got.DivergedAt != 0 {
+ t.Fatalf("a partial tape was reported as a divergence: %d %q", got.DivergedAt, got.Divergence)
+ }
+}
+
+// The healthy path must stay quiet: a recording that completed reports no
+// incomplete exchange and prints no line about one. Only the failure paths were
+// pinned before.
+func TestTapeCompleteRecordingReportsNothingIncomplete(t *testing.T) {
+ root := t.TempDir()
+ var calls atomic.Int32
+ m, base, stop := tapedMeter(t, tapeRecord, root, &calls)
+ defer stop()
+ readAll(t, post(t, base, "/chat/completions", tapedRequest))
+
+ var r result
+ runMeter{m: m, stop: stop}.record(&r)
+ if r.Meter == nil {
+ t.Fatal("record attached no meter")
+ }
+ if r.Meter.TapeIncomplete != 0 || r.Meter.TapeError != "" {
+ t.Fatalf("a complete recording reported %d incomplete exchanges (%q)", r.Meter.TapeIncomplete, r.Meter.TapeError)
+ }
+ // renderTapeRecord is the printer behind the report's summary block, so an
+ // empty string here is what "no line is printed" means.
+ if line := renderTapeRecord([]result{{task: task{ID: "bugfix-slug"}, Meter: r.Meter}}); line != "" {
+ t.Fatalf("a complete recording printed %q, want no line", line)
+ }
+}
diff --git a/cmd/e2ebench/trials.go b/cmd/e2ebench/trials.go
index 3cf0015441..57038be634 100644
--- a/cmd/e2ebench/trials.go
+++ b/cmd/e2ebench/trials.go
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
return tally, k
}
-// trialsLine reports reliability over repeated trials. pass^k counts a task
-// only when every trial it ran passed; a task cut short by the budget is
-// judged on the trials it has, and the line says how many were complete.
-// Empty for a run without trials, so older reports read as before.
+// trialsLine reports reliability over repeated trials. pass^k and pass@k are
+// over the tasks that ran all k trials (k is the highest trial index that ran);
+// a task cut short by the budget has no value for either and is listed apart.
+// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
+// without trials, so older reports read as before.
func trialsLine(results []result) string {
tally, k := tallyTrials(results)
if k < 2 || len(tally) == 0 {
return ""
}
all, any, complete, runs, passes := 0, 0, 0, 0, 0
- var flaky []string
+ var flaky, incomplete []string
for id, t := range tally {
runs += t.ran
passes += t.passed
- if t.ran == k {
- complete++
+ if t.passed > 0 && t.passed < t.ran {
+ flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
}
+ if t.ran < k {
+ incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
+ continue
+ }
+ complete++
if t.passed == t.ran {
all++
}
if t.passed > 0 {
any++
}
- if t.passed > 0 && t.passed < t.ran {
- flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
- }
}
sort.Strings(flaky)
+ sort.Strings(incomplete)
+ passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
+ if complete > 0 {
+ passAll, passAny = shareOf(all, complete), shareOf(any, complete)
+ }
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
- k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
+ k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
+ if len(incomplete) > 0 {
+ line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
+ }
if len(flaky) > 0 {
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
}
return line
}
+func shareOf(n, d int) string {
+ return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
+}
+
func share(n, d int) string {
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
}
diff --git a/cmd/e2ebench/trials_test.go b/cmd/e2ebench/trials_test.go
index 3dcddb37da..523b52a4c3 100644
--- a/cmd/e2ebench/trials_test.go
+++ b/cmd/e2ebench/trials_test.go
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
line := trialsLine(results)
for _, want := range []string{
"3 trials, 3/3 tasks complete",
- "**pass^3** 33% (1/3)",
- "**pass@3** 67% (2/3)",
+ "**pass^3** 33% (1/3 complete)",
+ "**pass@3** 67% (2/3 complete)",
"**mean pass@1** 56% (5/9)",
"`flaky` 2/3",
} {
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
}
}
-// A trial the budget skipped is not a failure; the task is judged on the
-// trials it ran, and the line says it was not complete.
-func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
- results := []result{
- trialResult("a", 1, true), trialResult("a", 2, true),
- trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
+// pass^k is the chance all k trials pass, which a task that ran fewer has no
+// value for. A trial the budget skipped is not a failure, so such a task is
+// left out of pass^k and pass@k and named, and its trials still count toward
+// mean pass@1.
+func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
+ for _, bPassed := range []bool{true, false} {
+ results := []result{
+ trialResult("a", 1, true), trialResult("a", 2, true),
+ trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
+ }
+ line := trialsLine(results)
+ for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
+ if !strings.Contains(line, want) {
+ t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
+ }
+ }
}
+}
+
+func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
+ results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
line := trialsLine(results)
- if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
+ if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
t.Fatalf("line = %s", line)
}
}
diff --git a/cmd/reasonix-studio-host/main.go b/cmd/reasonix-studio-host/main.go
index e125bf9b14..c8c8d29601 100644
--- a/cmd/reasonix-studio-host/main.go
+++ b/cmd/reasonix-studio-host/main.go
@@ -17,6 +17,7 @@ import (
"net/http"
"os"
"os/signal"
+ "path/filepath"
"strings"
"syscall"
"time"
@@ -67,6 +68,7 @@ type shellIdentity struct {
version string
exe string
pid int
+ logsDir string
}
func (s shellIdentity) stated() bool {
@@ -89,7 +91,7 @@ func studioInstall(shell shellIdentity) *update.Install {
// nil where the shell did not state enough to own one. Nil is the gate: the
// routes are not registered, so a host that cannot name the application it
// would replace has no way to be asked to replace it.
-func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
+func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
if !shell.stated() {
return nil
}
@@ -97,7 +99,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
// still work; the swap is what refuses, by name.
application, _ := update.ApplicationAt(shell.exe, shell.pid)
return appupdate.New(appupdate.Options{
- Owner: &shellOwner{to: to},
+ Owner: owner,
Running: shell.version,
Line: update.StudioLine(),
Application: application,
@@ -142,13 +144,22 @@ func main() {
return
}
boot.SetComputerHelper(*computerHelper)
- shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
+ shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
releaseFatalLog()
os.Exit(code)
}
+// logsDirOf is the shell's logs directory: the crash directory it passed is
+// the "crash" folder inside it.
+func logsDirOf(crashDir string) string {
+ if strings.TrimSpace(crashDir) == "" {
+ return ""
+ }
+ return filepath.Dir(crashDir)
+}
+
func defaultVersion(stated string) string {
if strings.TrimSpace(stated) != "" {
return stated
@@ -370,6 +381,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
<-ctx.Done()
share.Close()
}()
+ owner := &shellOwner{to: handshakeTo}
+ // A toast the user clicks is answered here, in the process that sent it, and
+ // the shell is the one that can bring its window forward.
+ notify.OnActivate(showOnToastClick(owner))
hub := serve.NewHub(serve.HubOptions{
Serve: hubCfg,
Surface: surface.Desktop,
@@ -378,13 +393,15 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
+ NotifySender: notify.NewPlatformSender(),
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
- Update: studioUpdateHost(shell, handshakeTo),
+ Update: studioUpdateHost(shell, owner),
Share: share,
+ LogsDir: shell.logsDir,
})
share.Attach(hub.Handler())
srv := serve.New(built.Controller, bc, hubCfg)
diff --git a/cmd/reasonix-studio-host/owner.go b/cmd/reasonix-studio-host/owner.go
index ab1d9c2dfe..0b7cad8675 100644
--- a/cmd/reasonix-studio-host/owner.go
+++ b/cmd/reasonix-studio-host/owner.go
@@ -9,13 +9,21 @@ import (
"sync"
)
-// The acts a handover asks of the shell. Only two travel: releasing the
-// application is this process's own hub, and the shell has nothing to do for it.
+// The acts this process asks of the shell. Releasing the application is this
+// process's own hub, so the shell has nothing to do for it; the other three are
+// what only the shell can do.
const (
actRelaunch = "relaunch"
actQuit = "quit"
+ actShow = "show"
)
+// showOnToastClick is what a clicked toast runs: ask the shell, which is the
+// only process that can bring its window forward, to do so.
+func showOnToastClick(o *shellOwner) func() {
+ return func() { _ = o.ask(actShow) }
+}
+
// shellOwner is the application this process was spawned by, reached over the
// pipe the handshake went out on -- the only channel pointing this way, since
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
diff --git a/cmd/reasonix-studio-host/owner_test.go b/cmd/reasonix-studio-host/owner_test.go
index c7030e26ac..f499a77338 100644
--- a/cmd/reasonix-studio-host/owner_test.go
+++ b/cmd/reasonix-studio-host/owner_test.go
@@ -78,7 +78,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
} {
t.Run(tc.name, func(t *testing.T) {
- if got := studioUpdateHost(tc.shell, &out); got != nil {
+ if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
}
})
@@ -89,7 +89,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
var out bytes.Buffer
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
- if studioUpdateHost(shell, &out) == nil {
+ if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
t.Fatal("a host that stated its application got no capability")
}
}
@@ -116,3 +116,13 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
}
}
+
+// A clicked toast reaches the shell as one line on the pipe the handshake used,
+// in the same shape the other acts take.
+func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
+ var out bytes.Buffer
+ showOnToastClick(&shellOwner{to: &out})()
+ if got := out.String(); got != "{\"act\":\"show\"}\n" {
+ t.Fatalf("pipe carried %q, want the show act", got)
+ }
+}
diff --git a/cmd/signpath-contract/windows_arch_test.go b/cmd/signpath-contract/windows_arch_test.go
index a3c232b5d4..40464f09eb 100644
--- a/cmd/signpath-contract/windows_arch_test.go
+++ b/cmd/signpath-contract/windows_arch_test.go
@@ -191,12 +191,13 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
- if !strings.Contains(run, `"$count" -lt 9`) {
- t.Error("publish does not require at least 9 ReasonixStudio-* packages")
+ if !strings.Contains(run, `"$count" -lt 10`) {
+ t.Error("publish does not require at least 10 ReasonixStudio-* packages")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
+ "ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
diff --git a/desktop/README.md b/desktop/README.md
index e1c9f54091..f595371346 100644
--- a/desktop/README.md
+++ b/desktop/README.md
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
| `electron/` | the shell: main process, preload bridge, packaging |
| `frontend-next/` | the SPA it serves |
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
-| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
+| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
| `internal/winuninstall/` | taking a previous Windows install over |
diff --git a/desktop/cmd/sign/main.go b/desktop/cmd/sign/main.go
index 9f0308de84..5ed024c93f 100644
--- a/desktop/cmd/sign/main.go
+++ b/desktop/cmd/sign/main.go
@@ -63,11 +63,6 @@ func main() {
notesVersion = os.Args[5]
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
- case "windows-payload":
- if len(os.Args) != 4 {
- usage()
- }
- err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -88,34 +83,10 @@ func main() {
}
func usage() {
- fmt.Fprintln(os.Stderr, "usage:\n sign ...\n manifest [notes-version]\n windows-payload \n genkey \n verify ")
+ fmt.Fprintln(os.Stderr, "usage:\n sign ...\n manifest [notes-version]\n genkey \n verify ")
os.Exit(2)
}
-func genWindowsPayloadManifest(dir, version string) error {
- hashes := make(map[string]string)
- for _, name := range update.WindowsPayloadFileNames() {
- path := filepath.Join(dir, name)
- info, err := os.Lstat(path)
- if err != nil {
- return fmt.Errorf("Windows payload %s: %w", name, err)
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("Windows payload %s is not a regular file", name)
- }
- _, sum, err := hashFile(path)
- if err != nil {
- return err
- }
- hashes[name] = sum
- }
- b, err := update.EncodeWindowsPayloadManifest(version, hashes)
- if err != nil {
- return err
- }
- return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
-}
-
// verifyFile checks against .minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
diff --git a/desktop/cmd/sign/main_test.go b/desktop/cmd/sign/main_test.go
index bd77eb1145..fe720ce405 100644
--- a/desktop/cmd/sign/main_test.go
+++ b/desktop/cmd/sign/main_test.go
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
-
-func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
- dir := t.TempDir()
- for _, name := range update.WindowsPayloadFileNames() {
- if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
- t.Fatal(err)
- }
- b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
- if err != nil {
- t.Fatal(err)
- }
- hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
- if err != nil {
- t.Fatal(err)
- }
- for _, name := range update.WindowsPayloadFileNames() {
- want := update.WindowsPayloadSHA256([]byte("payload:" + name))
- if hashes[name] != want {
- t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
- }
- }
-}
diff --git a/desktop/cmd/studio-manifest/arch_gate_test.go b/desktop/cmd/studio-manifest/arch_gate_test.go
index 290e978f61..bc7a792445 100644
--- a/desktop/cmd/studio-manifest/arch_gate_test.go
+++ b/desktop/cmd/studio-manifest/arch_gate_test.go
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"slices"
+ "strings"
"testing"
"reasonix/internal/platform/update"
@@ -13,7 +14,8 @@ import (
// archMap is desktop/electron/arch.json: what the packager builds for, and what
// each of those is called in a release.
type archMap struct {
- Map map[string]string `json:"map"`
+ Map map[string]string `json:"map"`
+ FPM map[string][]string `json:"fpm"`
}
func loadArchMap(t *testing.T) archMap {
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
}
}
}
+
+// fpm names an .rpm for the architecture its own way, and the parser accepts
+// that name too: it keys a package nobody asks for. The packager hook renames
+// these spellings, so every one must be a non-canonical key here.
+func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
+ m := loadArchMap(t)
+ if len(m.FPM) == 0 {
+ t.Fatal("arch.json lists no fpm spellings")
+ }
+ canonical := map[string]bool{}
+ for _, to := range m.Map {
+ canonical[to] = true
+ }
+ for from, spellings := range m.FPM {
+ to, ok := m.Map[from]
+ if !ok {
+ t.Errorf("fpm spells %s, which map does not release", from)
+ continue
+ }
+ if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
+ t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
+ }
+ for _, spelled := range spellings {
+ got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
+ if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
+ t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
+ }
+ }
+ }
+}
diff --git a/desktop/cmd/studio-manifest/main.go b/desktop/cmd/studio-manifest/main.go
index 4e68bb76ba..530f2e8e07 100644
--- a/desktop/cmd/studio-manifest/main.go
+++ b/desktop/cmd/studio-manifest/main.go
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
+ // The .rpm rides beside the .deb under its own key: the deb's key is the
+ // one every installed client already reads.
+ if key, ok := rpmPackageKey(name); ok {
+ m.NativePackages[key] = asset
+ fmt.Printf("native package: %s -> %s\n", name, key)
+ }
// Windows updates by running the next installer, so that is what the
// platform lookup has to resolve. The portable archive is never listed:
// resolving it would hand the updater an artifact it cannot install.
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
- strings.HasSuffix(name, ".deb")
+ strings.HasSuffix(name, ".deb") ||
+ strings.HasSuffix(name, ".rpm")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
return update.PlatformKey("linux", arch), true
}
+// rpmPackageKey maps a ReasonixStudio-linux-.rpm artifact to its
+// native_packages key.
+func rpmPackageKey(name string) (string, bool) {
+ base, ok := strings.CutSuffix(name, ".rpm")
+ if !ok {
+ return "", false
+ }
+ arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
+ if !ok || arch == "" {
+ return "", false
+ }
+ return update.RPMPackageKey("linux", arch), true
+}
+
func hashFile(path string) (int64, string, error) {
f, err := os.Open(path)
if err != nil {
diff --git a/desktop/cmd/studio-manifest/main_test.go b/desktop/cmd/studio-manifest/main_test.go
index e73ba98d6b..857b0eb941 100644
--- a/desktop/cmd/studio-manifest/main_test.go
+++ b/desktop/cmd/studio-manifest/main_test.go
@@ -232,3 +232,57 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
}
}
+
+// Every installed Linux client reads native_packages["linux-amd64"] as the
+// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
+func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
+ write := func(dir string, names ...string) {
+ for _, name := range names {
+ if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ generate := func(dir string) update.Manifest {
+ t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
+ if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ var m update.Manifest
+ raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := json.Unmarshal(raw, &m); err != nil {
+ t.Fatal(err)
+ }
+ return m
+ }
+ debOnly, both := t.TempDir(), t.TempDir()
+ write(debOnly, "ReasonixStudio-linux-amd64.deb")
+ write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
+ before, after := generate(debOnly), generate(both)
+
+ if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
+ t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
+ }
+ if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
+ t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
+ }
+ rpm, ok := after.NativePackages["linux-amd64-rpm"]
+ if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
+ t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
+ }
+ if len(after.NativePackages) != 2 {
+ t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
+ }
+ if _, ok := after.Platforms["linux-amd64"]; ok {
+ t.Error("the .rpm must not resolve as a platform asset")
+ }
+ if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
+ t.Error("downloads must offer the .rpm to a person")
+ }
+ if len(after.Downloads) != len(before.Downloads)+1 {
+ t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
+ }
+}
diff --git a/desktop/cmd/update-helper/args.go b/desktop/cmd/update-helper/args.go
deleted file mode 100644
index 5cc10fd049..0000000000
--- a/desktop/cmd/update-helper/args.go
+++ /dev/null
@@ -1,15 +0,0 @@
-package main
-
-import "fmt"
-
-func installerCommandLine(installer, dir string) string {
- // Auto-updates use a dedicated visible progress mode instead of NSIS /S.
- // The current helper also requires staging-only extraction: NSIS never writes
- // the live install, and the helper compare-and-publishes the claimed release
- // unit after validating every staged member.
- line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
- if dir != "" {
- line += " /D=" + dir
- }
- return line
-}
diff --git a/desktop/cmd/update-helper/args_test.go b/desktop/cmd/update-helper/args_test.go
deleted file mode 100644
index a908b1a29e..0000000000
--- a/desktop/cmd/update-helper/args_test.go
+++ /dev/null
@@ -1,23 +0,0 @@
-package main
-
-import (
- "strings"
- "testing"
-)
-
-func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
- got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
- want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
- if got != want {
- t.Fatalf("installerCommandLine = %q, want %q", got, want)
- }
- if strings.Contains(got, " /S") {
- t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
- }
- if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
- t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
- }
- if !strings.Contains(got, " /REASONIXSTAGE=1") {
- t.Fatalf("auto-update must extract away from the live install, got %q", got)
- }
-}
diff --git a/desktop/cmd/update-helper/authenticode_other.go b/desktop/cmd/update-helper/authenticode_other.go
deleted file mode 100644
index b2c8a0b65e..0000000000
--- a/desktop/cmd/update-helper/authenticode_other.go
+++ /dev/null
@@ -1,10 +0,0 @@
-//go:build !windows
-
-package main
-
-import "os"
-
-// Windows verifies each executable with the OS Authenticode policy while the
-// same read handle is held. Non-Windows builds keep the release-unit loader
-// testable without importing platform-specific trust APIs.
-var readVerifiedWindowsStagedPayloadFn = os.ReadFile
diff --git a/desktop/cmd/update-helper/authenticode_windows.go b/desktop/cmd/update-helper/authenticode_windows.go
deleted file mode 100644
index b8f3fb0b39..0000000000
--- a/desktop/cmd/update-helper/authenticode_windows.go
+++ /dev/null
@@ -1,97 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "fmt"
- "io"
- "os"
- "unsafe"
-
- "golang.org/x/sys/windows"
-)
-
-var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
-
-// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
-// reads the payload through one handle that disallows concurrent writes,
-// deletes, and renames. The bytes handed to the publisher therefore cannot
-// change between signature verification and publication.
-func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
- info, err := os.Lstat(path)
- if err != nil {
- return nil, err
- }
- if !info.Mode().IsRegular() {
- return nil, fmt.Errorf("staged payload is not a regular file")
- }
- path16, err := windows.UTF16PtrFromString(path)
- if err != nil {
- return nil, err
- }
- handle, err := windows.CreateFile(
- path16,
- windows.GENERIC_READ,
- windows.FILE_SHARE_READ,
- nil,
- windows.OPEN_EXISTING,
- windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
- 0,
- )
- if err != nil {
- return nil, err
- }
- file := os.NewFile(uintptr(handle), path)
- if file == nil {
- _ = windows.CloseHandle(handle)
- return nil, fmt.Errorf("open staged payload handle")
- }
- defer file.Close()
-
- fileInfo, err := file.Stat()
- if err != nil {
- return nil, err
- }
- if !fileInfo.Mode().IsRegular() {
- return nil, fmt.Errorf("staged payload is not a regular file")
- }
-
- trustFile := &windows.WinTrustFileInfo{
- Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
- FilePath: path16,
- File: handle,
- }
- trustData := &windows.WinTrustData{
- Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
- UIChoice: windows.WTD_UI_NONE,
- RevocationChecks: windows.WTD_REVOKE_NONE,
- UnionChoice: windows.WTD_CHOICE_FILE,
- FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
- StateAction: windows.WTD_STATEACTION_VERIFY,
- UIContext: windows.WTD_UICONTEXT_EXECUTE,
- }
- verifyErr := windows.WinVerifyTrustEx(
- windows.InvalidHWND,
- &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
- trustData,
- )
- trustData.StateAction = windows.WTD_STATEACTION_CLOSE
- closeErr := windows.WinVerifyTrustEx(
- windows.InvalidHWND,
- &windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
- trustData,
- )
- if verifyErr != nil {
- if closeErr != nil {
- return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
- }
- return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
- }
- if closeErr != nil {
- return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
- }
- if _, err := file.Seek(0, io.SeekStart); err != nil {
- return nil, err
- }
- return io.ReadAll(file)
-}
diff --git a/desktop/cmd/update-helper/main_linux.go b/desktop/cmd/update-helper/main_linux.go
index f86a894f4e..7c6c4a8d0c 100644
--- a/desktop/cmd/update-helper/main_linux.go
+++ b/desktop/cmd/update-helper/main_linux.go
@@ -37,11 +37,11 @@ const (
exitPostVerify = 16
)
-// packageName is the only dpkg package this helper will install, fixed at
-// build time so no runtime input can redirect it. Studio builds the same
-// source with its own name; each package installs its own binary, and Polkit
-// authorizes that path alone.
-var packageName = "reasonix-desktop"
+// packageName is the only dpkg package this helper will install. It is Studio's
+// line as the update path states it, compiled in, so no runtime input can
+// redirect it and no build step has a value to forget; Polkit authorizes this
+// helper's own path alone.
+var packageName = update.StudioLine().Deb.Package
const (
dpkgDebPath = "/usr/bin/dpkg-deb"
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
switch args[0] {
case "install":
return runInstall(d, args[1:])
+ case "package":
+ fmt.Fprintln(os.Stdout, packageName)
+ return exitOK
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
diff --git a/desktop/cmd/update-helper/main_linux_test.go b/desktop/cmd/update-helper/main_linux_test.go
index 1afdbb33d0..948cea4fce 100644
--- a/desktop/cmd/update-helper/main_linux_test.go
+++ b/desktop/cmd/update-helper/main_linux_test.go
@@ -3,6 +3,7 @@
package main
import (
+ "bytes"
"crypto/rand"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/tempdir"
+ "reasonix/internal/platform/update"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
+
+// The helper installs one package and the shell asks dpkg about the same one:
+// both must come from the update path's own line, or an in-app update is
+// refused as a different package.
+func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
+ if packageName != update.StudioLine().Deb.Package {
+ t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
+ }
+ var out bytes.Buffer
+ old := os.Stdout
+ r, w, _ := os.Pipe()
+ os.Stdout = w
+ code := runWith(realDeps(), []string{"package"})
+ w.Close()
+ os.Stdout = old
+ _, _ = out.ReadFrom(r)
+ if code != exitOK || strings.TrimSpace(out.String()) != packageName {
+ t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
+ }
+}
+
+// electron-builder names the packages; the helper must agree with both.
+func TestPackagedNamesMatchTheHelper(t *testing.T) {
+ raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ names := map[string]string{}
+ section := ""
+ for line := range strings.SplitSeq(string(raw), "\n") {
+ if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
+ section = strings.TrimSuffix(strings.TrimSpace(line), ":")
+ }
+ if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
+ names[section] = strings.TrimSpace(rest)
+ }
+ }
+ if names["deb"] != packageName || names["rpm"] != packageName {
+ t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
+ }
+}
diff --git a/desktop/cmd/update-helper/main_other.go b/desktop/cmd/update-helper/main_other.go
index c41f017f12..df9b36f3f9 100644
--- a/desktop/cmd/update-helper/main_other.go
+++ b/desktop/cmd/update-helper/main_other.go
@@ -1,4 +1,4 @@
-//go:build !windows && !linux
+//go:build !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
- fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
+ fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
os.Exit(2)
}
diff --git a/desktop/cmd/update-helper/main_windows.go b/desktop/cmd/update-helper/main_windows.go
deleted file mode 100644
index 4faf9b2de7..0000000000
--- a/desktop/cmd/update-helper/main_windows.go
+++ /dev/null
@@ -1,581 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "flag"
- "fmt"
- "io"
- "log"
- "os"
- "os/exec"
- "path/filepath"
- "strings"
- "sync"
- "syscall"
- "time"
-
- "golang.org/x/sys/windows"
-
- "reasonix/desktop/internal/winuninstall"
- "reasonix/internal/platform/installlayout"
- "reasonix/internal/platform/repair"
-)
-
-const parentExitTimeout = 2 * time.Minute
-
-var (
- waitForProcessExitFn = waitForProcessExit
- runInstallerFn = runInstaller
- startRelaunchFn = startRelaunch
- claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
- installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
- recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
- stageInstallerFn = stageVerifiedInstaller
- claimInstallerExecutionFn = claimVerifiedInstallerForExecution
- lstatUpdateStagingFn = os.Lstat
- reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
-)
-
-func main() {
- os.Exit(run(os.Args[1:]))
-}
-
-func run(args []string) int {
- var parentPID uint
- var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
- fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
- fs.SetOutput(os.Stderr)
- fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
- fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
- fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
- fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
- fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
- fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
- fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
- fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
- fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
- if err := fs.Parse(args); err != nil {
- return 2
- }
- logger := newLogger()
- if installer == "" {
- logger.Print("missing --installer")
- return 2
- }
- if !validSHA256(installerSHA256) {
- logger.Print("missing or invalid --installer-sha256")
- return 2
- }
- if toVersion == "" {
- logger.Print("missing --to-version")
- return 2
- }
- if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
- logger.Printf("unsupported --install-layout %q", installLayout)
- return 2
- }
- if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
- logger.Print("missing --created-at")
- return 2
- }
- if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
- logger.Print("missing --transaction-id")
- return 2
- }
- if installDir == "" {
- logger.Print("missing --install-dir")
- return 2
- }
- if parentPID != 0 {
- if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
- logger.Printf("wait for parent process %d: %v", parentPID, err)
- return 1
- }
- }
- if installLayout == installlayout.InstallLayoutVersionedV1 {
- return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
- }
- recoverExisting := func(reason string) int {
- if relaunch != "" {
- if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
- logger.Printf("relaunch after %s: %v", reason, relaunchErr)
- }
- }
- return 1
- }
- // The detached helper runs from the update cache, so a plain
- // repair.ReadPendingUpdate would validate the transaction against the cache
- // directory and reject every legitimate flat install. Claim through the
- // explicit install-local launcher path instead; the repair package validates
- // the complete transaction identity and exact release-unit path set while it
- // acquires the pending + target locks.
- claimTargets := windowsReleaseUnitPaths(installDir)
- claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
- // The old desktop may acquire the pending lock during its normal shutdown.
- // Wait for that exact process first, then claim the transaction and hold both
- // pending and target locks across the installer replacement window.
- claimed, releaseClaim, err := claimPendingFileUpdateFn(
- toVersion,
- createdAt,
- transactionID,
- claimLauncher,
- claimTargets,
- parentExitTimeout,
- )
- if err != nil {
- logger.Printf("claim pending update: %v", err)
- return recoverExisting("pending update claim failure")
- }
- defer releaseClaim()
- recoverUnstarted := func() int {
- releaseClaim()
- if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
- logger.Printf("cancel unstarted update: %v", cancelErr)
- }
- if relaunch != "" {
- if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
- logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
- }
- }
- return 1
- }
- claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
- if err != nil {
- logger.Printf("bind update installer: %v", err)
- return recoverUnstarted()
- }
- defer func() {
- if cleanupErr := cleanupInstaller(); cleanupErr != nil {
- logger.Printf("preserving update installer staging: %v", cleanupErr)
- }
- }()
- stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
- if err != nil {
- logger.Printf("create update staging: %v", err)
- return recoverUnstarted()
- }
- stagingOwner, err := lstatUpdateStagingFn(stagingDir)
- if err != nil {
- logger.Printf("bind update staging: %v", err)
- return recoverUnstarted()
- }
- defer func() {
- if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
- logger.Printf("preserving update staging: %v", cleanupErr)
- }
- }()
- releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
- if err != nil {
- logger.Printf("recheck staged installer: %v", err)
- return recoverUnstarted()
- }
- installerErr := runInstallerFn(claimedInstaller, stagingDir)
- releaseInstallerExecution()
- if installerErr != nil {
- logger.Printf("extract installer payload: %v", installerErr)
- return recoverUnstarted()
- }
- publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
- if err != nil {
- logger.Printf("publish staged release unit: %v", err)
- if !publishStarted {
- releaseClaim()
- if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
- logger.Printf("cancel unstarted update: %v", cancelErr)
- }
- }
- if relaunch != "" {
- if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
- logger.Printf("relaunch after publish failure: %v", relaunchErr)
- }
- }
- return 1
- }
- if receipts == nil {
- // Versioned-v1 activation: no flat publish receipts and no health
- // probation. Clear the pending transaction and relaunch the launcher.
- releaseClaim()
- if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
- logger.Printf("clear pending after versioned activate: %v", cancelErr)
- }
- if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
- logger.Printf("clear apply failure after versioned activate: %v", clearErr)
- }
- } else {
- if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
- logger.Printf("record installed release unit: %v", err)
- if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
- logger.Printf("record install failure: %v", markErr)
- }
- if relaunch != "" {
- if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
- logger.Printf("relaunch after failed install verification: %v", relaunchErr)
- }
- }
- return 1
- }
- if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
- logger.Printf("clear completed update marker: %v", err)
- }
- }
- if relaunch != "" {
- if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
- logger.Printf("relaunch: %v", err)
- return 1
- }
- }
- return 0
-}
-
-func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
- recoverExisting := func() int {
- if relaunch != "" {
- if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
- logger.Printf("relaunch after versioned update failure: %v", err)
- }
- }
- return 1
- }
- if _, err := installlayout.ReadCurrent(installDir); err != nil {
- logger.Printf("read versioned install pointer: %v", err)
- return recoverExisting()
- }
- claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
- if err != nil {
- logger.Printf("bind versioned update installer: %v", err)
- return recoverExisting()
- }
- defer func() {
- if cleanupErr := cleanupInstaller(); cleanupErr != nil {
- logger.Printf("preserving update installer staging: %v", cleanupErr)
- }
- }()
- stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
- if err != nil {
- logger.Printf("create versioned update staging: %v", err)
- return recoverExisting()
- }
- stagingOwner, err := lstatUpdateStagingFn(stagingDir)
- if err != nil {
- logger.Printf("bind versioned update staging: %v", err)
- return recoverExisting()
- }
- defer func() {
- if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
- logger.Printf("preserving update staging: %v", cleanupErr)
- }
- }()
- releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
- if err != nil {
- logger.Printf("recheck staged versioned installer: %v", err)
- return recoverExisting()
- }
- installerErr := runInstallerFn(claimedInstaller, stagingDir)
- releaseInstallerExecution()
- if installerErr != nil {
- logger.Printf("extract versioned installer payload: %v", installerErr)
- return recoverExisting()
- }
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: toVersion,
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- }
- if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
- logger.Printf("activate versioned release: %v", err)
- return recoverExisting()
- }
- if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
- // The release is already active and must not be rolled back for stale
- // Add/Remove Programs metadata. A later update or full installer retries
- // this idempotent reconciliation.
- logger.Printf("reconcile Windows uninstall registration: %v", err)
- }
- if relaunch != "" {
- if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
- logger.Printf("relaunch versioned release: %v", err)
- return 1
- }
- }
- return 0
-}
-
-// preferRelaunchPath chooses the thin launcher when present so post-update
-// restarts use the permanent entry point, not a flat desktop binary.
-func preferRelaunchPath(relaunch, installDir string) string {
- for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
- path := filepath.Join(installDir, name)
- if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
- return path
- }
- }
- return relaunch
-}
-
-func validSHA256(value string) bool {
- value = strings.TrimSpace(value)
- if len(value) != sha256.Size*2 {
- return false
- }
- _, err := hex.DecodeString(value)
- return err == nil
-}
-
-func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
- if !validSHA256(expectedSHA256) {
- return "", nil, fmt.Errorf("installer SHA-256 is invalid")
- }
- sourceInfo, err := os.Lstat(sourcePath)
- if err != nil {
- return "", nil, err
- }
- if !sourceInfo.Mode().IsRegular() {
- return "", nil, fmt.Errorf("installer is not a regular file")
- }
- dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
- if err != nil {
- return "", nil, err
- }
- owner, err := os.Lstat(dir)
- if err != nil {
- return "", nil, err
- }
- cleanup := func() error {
- return cleanupOwnedWindowsUpdateDirectory(dir, owner)
- }
- fail := func(err error) (string, func() error, error) {
- _ = cleanup()
- return "", nil, err
- }
- source, err := os.Open(sourcePath)
- if err != nil {
- return fail(err)
- }
- defer source.Close()
- stagedPath := filepath.Join(dir, "reasonix-installer.exe")
- staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
- if err != nil {
- return fail(err)
- }
- hash := sha256.New()
- _, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
- syncErr := staged.Sync()
- closeErr := staged.Close()
- switch {
- case copyErr != nil:
- return fail(copyErr)
- case syncErr != nil:
- return fail(syncErr)
- case closeErr != nil:
- return fail(closeErr)
- case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
- return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
- }
- if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
- return fail(err)
- }
- return stagedPath, cleanup, nil
-}
-
-func verifyInstallerSHA256(path, expectedSHA256 string) error {
- info, err := os.Lstat(path)
- if err != nil {
- return err
- }
- if !info.Mode().IsRegular() {
- return fmt.Errorf("installer is not a regular file")
- }
- f, err := os.Open(path)
- if err != nil {
- return err
- }
- defer f.Close()
- hash := sha256.New()
- if _, err := io.Copy(hash, f); err != nil {
- return err
- }
- if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
- return fmt.Errorf("installer SHA-256 mismatch")
- }
- return nil
-}
-
-func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
- if !validSHA256(expectedSHA256) {
- return nil, fmt.Errorf("installer SHA-256 is invalid")
- }
- pathUTF16, err := windows.UTF16PtrFromString(path)
- if err != nil {
- return nil, err
- }
- handle, err := windows.CreateFile(
- pathUTF16,
- windows.GENERIC_READ,
- windows.FILE_SHARE_READ,
- nil,
- windows.OPEN_EXISTING,
- windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
- 0,
- )
- if err != nil {
- return nil, err
- }
- file := os.NewFile(uintptr(handle), path)
- fail := func(err error) (func(), error) {
- _ = file.Close()
- return nil, err
- }
- info, err := file.Stat()
- if err != nil {
- return fail(err)
- }
- if !info.Mode().IsRegular() {
- return fail(fmt.Errorf("installer is not a regular file"))
- }
- hash := sha256.New()
- if _, err := io.Copy(hash, file); err != nil {
- return fail(err)
- }
- if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
- return fail(fmt.Errorf("installer SHA-256 mismatch"))
- }
- var once sync.Once
- return func() {
- once.Do(func() { _ = file.Close() })
- }, nil
-}
-
-func installStagedWindowsReleaseUnit(
- claimed *repair.UpdateTransaction,
- stagingDir string,
-) (bool, []repair.FileUpdateInstallReceipt, error) {
- // v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
- // fail closed without mutating the live install or leaving flat pending state.
- if !preferVersionedWindowsActivation(stagingDir) {
- return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
- }
- if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
- return false, nil, fmt.Errorf("versioned activate: %w", err)
- }
- return true, nil, nil
-}
-
-func windowsReleaseUnitPaths(installDir string) []string {
- if installDir == "" {
- return nil
- }
- names := []string{
- "reasonix-desktop.exe",
- "reasonix-guard.exe",
- "reasonix-launcher.exe",
- "reasonix-update-helper.exe",
- "reasonix-cli.exe",
- "Reasonix.exe",
- }
- paths := make([]string, 0, len(names))
- for _, name := range names {
- paths = append(paths, filepath.Join(installDir, name))
- }
- return paths
-}
-
-func newLogger() *log.Logger {
- dir, err := os.UserCacheDir()
- if err == nil {
- dir = filepath.Join(dir, "Reasonix", "updates")
- if err := os.MkdirAll(dir, 0o700); err == nil {
- if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
- return log.New(f, "", log.LstdFlags)
- }
- }
- }
- return log.New(os.Stderr, "", log.LstdFlags)
-}
-
-func waitForProcessExit(pid uint32, timeout time.Duration) error {
- h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
- if err != nil {
- if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
- return nil
- }
- return err
- }
- defer windows.CloseHandle(h)
- waitMS := uint32(timeout / time.Millisecond)
- result, err := windows.WaitForSingleObject(h, waitMS)
- if err != nil {
- return err
- }
- switch result {
- case windows.WAIT_OBJECT_0:
- return nil
- case uint32(windows.WAIT_TIMEOUT):
- return fmt.Errorf("timed out after %s", timeout)
- default:
- return fmt.Errorf("unexpected wait result %d", result)
- }
-}
-
-func runInstaller(installer, installDir string) error {
- cmd := exec.Command(installer)
- // Keep the helper itself hidden, but let the NSIS update-progress window be
- // visible. /REASONIXSTAGE makes the signed installer extract only; the helper
- // performs every live replacement through the claimed transaction.
- cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
- return cmd.Run()
-}
-
-func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
- if path == "" || owner == nil || !owner.IsDir() {
- return fmt.Errorf("Windows update cleanup identity is incomplete")
- }
- for attempt := range 16 {
- cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
- from, err := windows.UTF16PtrFromString(path)
- if err != nil {
- return err
- }
- to, err := windows.UTF16PtrFromString(cleanup)
- if err != nil {
- return err
- }
- if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
- if os.IsNotExist(err) {
- return nil
- }
- if os.IsExist(err) {
- continue
- }
- return err
- }
- actual, err := os.Lstat(cleanup)
- if err != nil {
- return err
- }
- if !os.SameFile(owner, actual) {
- restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
- restoreTo, toErr := windows.UTF16PtrFromString(path)
- if fromErr != nil || toErr != nil {
- return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
- }
- if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
- return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
- }
- return fmt.Errorf("Windows update staging changed before cleanup")
- }
- return os.RemoveAll(cleanup)
- }
- return fmt.Errorf("cannot allocate Windows update cleanup path")
-}
-
-func startRelaunch(relaunch, installDir string) error {
- cmd := exec.Command(relaunch)
- cmd.Dir = installDir
- cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
- return cmd.Start()
-}
diff --git a/desktop/cmd/update-helper/main_windows_test.go b/desktop/cmd/update-helper/main_windows_test.go
deleted file mode 100644
index f6e2e8d9e7..0000000000
--- a/desktop/cmd/update-helper/main_windows_test.go
+++ /dev/null
@@ -1,563 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
- "time"
-
- "reasonix/internal/platform/installlayout"
- "reasonix/internal/platform/repair"
-
- "reasonix/internal/base/tempdir"
-)
-
-const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
-
-func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
- t.Helper()
- paths := windowsReleaseUnitPaths(installDir)
- for _, path := range paths {
- if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
- if err != nil {
- t.Fatal(err)
- }
- return tx
-}
-
-func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
- source := filepath.Join(tempdir.New(t), "installer.exe")
- content := []byte("verified-installer")
- if err := os.WriteFile(source, content, 0o700); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- expected := hex.EncodeToString(sum[:])
- staged, cleanup, err := stageVerifiedInstaller(source, expected)
- if err != nil {
- t.Fatal(err)
- }
- t.Cleanup(func() { _ = cleanup() })
- if staged == source {
- t.Fatal("installer was not isolated from the mutable cache path")
- }
- if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := verifyInstallerSHA256(staged, expected); err != nil {
- t.Fatalf("isolated installer changed with source: %v", err)
- }
- if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
- t.Fatalf("staged installer = %q, %v", got, err)
- }
- if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := verifyInstallerSHA256(staged, expected); err == nil {
- t.Fatal("tampered staged installer passed final verification")
- }
-}
-
-func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
- source := filepath.Join(tempdir.New(t), "installer.exe")
- if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
- t.Fatal(err)
- }
- if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
- if cleanup != nil {
- _ = cleanup()
- }
- t.Fatal("installer bytes different from desktop verification were accepted")
- }
-}
-
-func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
- if code := run([]string{
- "--installer", `C:\Temp\Reasonix-installer.exe`,
- "--installer-sha256", testInstallerSHA256,
- }); code != 2 {
- t.Fatalf("run without --to-version = %d, want 2", code)
- }
-}
-
-func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
- installDir := tempdir.New(t)
- seed := tempdir.New(t)
- for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
- if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
- InstallRoot: installDir,
- Version: "v1.20.0",
- RequestID: "seed-windows-helper",
- Members: []installlayout.Member{
- {Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
- {Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
- {Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
- },
- RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
- }); err != nil {
- t.Fatal(err)
- }
-
- originalInstaller := runInstallerFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- originalStageInstaller := stageInstallerFn
- originalClaimInstallerExecution := claimInstallerExecutionFn
- originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
- t.Cleanup(func() {
- runInstallerFn = originalInstaller
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- stageInstallerFn = originalStageInstaller
- claimInstallerExecutionFn = originalClaimInstallerExecution
- reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
- })
- legacyClaimed := false
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- legacyClaimed = true
- return nil, func() {}, errors.New("legacy claim must not run")
- }
- stageInstallerFn = func(path, _ string) (string, func() error, error) {
- return path, func() error { return nil }, nil
- }
- claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
- reconciledVersion := ""
- reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
- if root != installDir {
- t.Fatalf("reconcile root = %q, want %q", root, installDir)
- }
- reconciledVersion = targetVersion
- return true, nil
- }
- runInstallerFn = func(_ string, staging string) error {
- for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
- if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
- return err
- }
- }
- return nil
- }
- relaunched := false
- startRelaunchFn = func(string, string) error { relaunched = true; return nil }
-
- if code := run([]string{
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
- "--to-version", "v1.20.1",
- "--install-layout", "versioned-v1",
- }); code != 0 {
- t.Fatalf("run exit code = %d", code)
- }
- if legacyClaimed {
- t.Fatal("versioned update claimed legacy pending transaction")
- }
- if !relaunched {
- t.Fatal("versioned update did not relaunch")
- }
- if reconciledVersion != "v1.20.1" {
- t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
- }
- ptr, err := installlayout.ReadCurrent(installDir)
- if err != nil || ptr.ActiveVersion != "v1.20.1" {
- t.Fatalf("pointer=%+v err=%v", ptr, err)
- }
-}
-
-func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
- t.Setenv("REASONIX_HOME", tempdir.New(t))
- installDir := tempdir.New(t)
- pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
- var events []string
- originalWait := waitForProcessExitFn
- originalInstaller := runInstallerFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- originalInstallStaged := installStagedReleaseUnitFn
- originalRecord := recordInstalledUpdateFn
- originalStageInstaller := stageInstallerFn
- originalClaimInstallerExecution := claimInstallerExecutionFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- runInstallerFn = originalInstaller
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- installStagedReleaseUnitFn = originalInstallStaged
- recordInstalledUpdateFn = originalRecord
- stageInstallerFn = originalStageInstaller
- claimInstallerExecutionFn = originalClaimInstallerExecution
- })
- waitForProcessExitFn = func(uint32, time.Duration) error {
- events = append(events, "wait")
- return nil
- }
- runInstallerFn = func(string, string) error {
- events = append(events, "installer")
- return nil
- }
- startRelaunchFn = func(string, string) error {
- events = append(events, "relaunch")
- return nil
- }
- claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
- events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
- return pending, func() { events = append(events, "release") }, nil
- }
- installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
- events = append(events, "publish")
- return true, []repair.FileUpdateInstallReceipt{}, nil
- }
- recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
- events = append(events, "record-installed")
- return tx, nil
- }
- stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
- events = append(events, "stage-installer:"+sha256)
- return path + ".claimed", func() error { return nil }, nil
- }
- claimInstallerExecutionFn = func(string, string) (func(), error) {
- events = append(events, "claim-installer")
- return func() { events = append(events, "release-installer") }, nil
- }
-
- if code := run([]string{
- "--parent-pid", "1234",
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
- "--to-version", pending.ToVersion,
- "--created-at", pending.CreatedAt,
- "--transaction-id", repair.UpdateTransactionID(pending),
- }); code != 0 {
- t.Fatalf("run exit code = %d", code)
- }
- wantClaim := "claim:" + strings.Join([]string{
- pending.ToVersion,
- pending.CreatedAt,
- repair.UpdateTransactionID(pending),
- filepath.Join(installDir, "reasonix-desktop.exe"),
- strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
- }, "\x01")
- if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
- events[2] != "stage-installer:"+testInstallerSHA256 ||
- events[3] != "claim-installer" || events[4] != "installer" ||
- events[5] != "release-installer" || events[6] != "publish" ||
- events[7] != "record-installed" || events[8] != "relaunch" ||
- events[9] != "release" {
- t.Fatalf("handoff events = %#v", events)
- }
-}
-
-func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
- installDir := tempdir.New(t)
- originalWait := waitForProcessExitFn
- originalInstaller := runInstallerFn
- originalClaim := claimPendingFileUpdateFn
- originalRecord := recordInstalledUpdateFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- runInstallerFn = originalInstaller
- claimPendingFileUpdateFn = originalClaim
- recordInstalledUpdateFn = originalRecord
- })
- waitForProcessExitFn = func(uint32, time.Duration) error {
- return errors.New("parent still running")
- }
- claimed := false
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- claimed = true
- return &repair.UpdateTransaction{}, func() {}, nil
- }
- installerRan := false
- runInstallerFn = func(string, string) error {
- installerRan = true
- return nil
- }
-
- if code := run([]string{
- "--parent-pid", "1234",
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--to-version", "v2",
- "--created-at", "2026-07-29T00:00:00Z",
- "--transaction-id", "transaction-1",
- }); code != 1 {
- t.Fatalf("run exit code = %d, want 1", code)
- }
- if claimed || installerRan {
- t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
- }
-}
-
-func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
- installDir := tempdir.New(t)
- originalWait := waitForProcessExitFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- })
-
- waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
- relaunched := false
- startRelaunchFn = func(path, dir string) error {
- relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
- return nil
- }
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- return nil, nil, errors.New("pending lock unavailable")
- }
-
- if code := run([]string{
- "--parent-pid", "1234",
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
- "--to-version", "v2",
- "--created-at", "2026-08-02T00:00:00Z",
- "--transaction-id", strings.Repeat("a", 64),
- }); code != 1 {
- t.Fatalf("run exit code = %d, want 1", code)
- }
- if !relaunched {
- t.Fatal("helper did not relaunch the existing desktop")
- }
-}
-
-func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
- t.Setenv("REASONIX_HOME", tempdir.New(t))
- installDir := tempdir.New(t)
- originalWait := waitForProcessExitFn
- originalInstaller := runInstallerFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- originalInstallStaged := installStagedReleaseUnitFn
- originalRecord := recordInstalledUpdateFn
- originalStageInstaller := stageInstallerFn
- originalClaimInstallerExecution := claimInstallerExecutionFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- runInstallerFn = originalInstaller
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- installStagedReleaseUnitFn = originalInstallStaged
- recordInstalledUpdateFn = originalRecord
- stageInstallerFn = originalStageInstaller
- claimInstallerExecutionFn = originalClaimInstallerExecution
- })
- waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
- runInstallerFn = func(string, string) error { return errors.New("installer failed") }
- startRelaunchFn = func(string, string) error { return nil }
- claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- return claimed, func() {}, nil
- }
- stageInstallerFn = func(path, _ string) (string, func() error, error) {
- return path, func() error { return nil }, nil
- }
- claimInstallerExecutionFn = func(string, string) (func(), error) {
- return func() {}, nil
- }
- transactionID := repair.UpdateTransactionID(claimed)
- if code := run([]string{
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
- "--to-version", "v2",
- "--created-at", claimed.CreatedAt,
- "--transaction-id", transactionID,
- }); code != 1 {
- t.Fatalf("run exit code = %d, want 1", code)
- }
- if _, ok := repair.ReadUpdateApplyFailure(); ok {
- t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
- }
-}
-
-func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
- t.Setenv("REASONIX_HOME", tempdir.New(t))
- installDir := tempdir.New(t)
- originalWait := waitForProcessExitFn
- originalInstaller := runInstallerFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- originalRecord := recordInstalledUpdateFn
- originalStageInstaller := stageInstallerFn
- originalClaimInstallerExecution := claimInstallerExecutionFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- runInstallerFn = originalInstaller
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- recordInstalledUpdateFn = originalRecord
- stageInstallerFn = originalStageInstaller
- claimInstallerExecutionFn = originalClaimInstallerExecution
- })
- waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
- runInstallerFn = func(string, string) error { return nil }
- relaunched := false
- startRelaunchFn = func(string, string) error {
- relaunched = true
- return nil
- }
- claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- return claimed, func() {}, nil
- }
- stageInstallerFn = func(path, _ string) (string, func() error, error) {
- return path, func() error { return nil }, nil
- }
- claimInstallerExecutionFn = func(string, string) (func(), error) {
- return func() {}, nil
- }
- installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
- return true, []repair.FileUpdateInstallReceipt{{
- UpdateTransactionID: repair.UpdateTransactionID(claimed),
- TargetPath: claimed.TargetPath,
- InstalledStateID: "installed",
- }}, nil
- }
- recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
- return nil, errors.New("release unit drifted")
- }
- transactionID := repair.UpdateTransactionID(claimed)
-
- if code := run([]string{
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
- "--to-version", "v2",
- "--created-at", claimed.CreatedAt,
- "--transaction-id", transactionID,
- }); code != 1 {
- t.Fatalf("run exit code = %d, want 1", code)
- }
- if !relaunched {
- t.Fatal("helper did not relaunch through recovery after post-install verification failure")
- }
- failure, ok := repair.ReadUpdateApplyFailure()
- if !ok || failure.UpdateTransactionID != transactionID {
- t.Fatalf("failure marker = %+v, present=%v", failure, ok)
- }
-}
-
-func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
- t.Setenv("REASONIX_HOME", tempdir.New(t))
- installDir := tempdir.New(t)
- originalWait := waitForProcessExitFn
- originalInstaller := runInstallerFn
- originalRelaunch := startRelaunchFn
- originalClaim := claimPendingFileUpdateFn
- originalStageInstaller := stageInstallerFn
- originalLstatStaging := lstatUpdateStagingFn
- t.Cleanup(func() {
- waitForProcessExitFn = originalWait
- runInstallerFn = originalInstaller
- startRelaunchFn = originalRelaunch
- claimPendingFileUpdateFn = originalClaim
- stageInstallerFn = originalStageInstaller
- lstatUpdateStagingFn = originalLstatStaging
- })
- waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
- installerRan := false
- runInstallerFn = func(string, string) error {
- installerRan = true
- return nil
- }
- relaunched := false
- startRelaunchFn = func(string, string) error {
- relaunched = true
- return nil
- }
- claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
- releases := 0
- claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
- return claimed, func() { releases++ }, nil
- }
- stageInstallerFn = func(path, _ string) (string, func() error, error) {
- return path, func() error { return nil }, nil
- }
- lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
- return nil, errors.New("staging identity unavailable")
- }
-
- if code := run([]string{
- "--installer", filepath.Join(installDir, "installer.exe"),
- "--installer-sha256", testInstallerSHA256,
- "--install-dir", installDir,
- "--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
- "--to-version", claimed.ToVersion,
- "--created-at", claimed.CreatedAt,
- "--transaction-id", repair.UpdateTransactionID(claimed),
- }); code != 1 {
- t.Fatalf("run exit code = %d, want 1", code)
- }
- if installerRan {
- t.Fatal("installer ran without a bound staging directory")
- }
- if !relaunched {
- t.Fatal("helper did not relaunch after staging identity failure")
- }
- if releases == 0 {
- t.Fatal("helper did not release the update claim")
- }
-}
-
-func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
- path := filepath.Join(tempdir.New(t), "installer.exe")
- content := []byte("verified-installer")
- if err := os.WriteFile(path, content, 0o700); err != nil {
- t.Fatal(err)
- }
- sum := sha256.Sum256(content)
- release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
- release()
- t.Fatal("installer remained writable while execution claim was held")
- }
- if err := os.Rename(path, path+".replaced"); err == nil {
- release()
- t.Fatal("installer remained renameable while execution claim was held")
- }
- release()
- release()
- if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
- t.Fatalf("installer remained frozen after claim release: %v", err)
- }
-}
-
-func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
- path := filepath.Join(tempdir.New(t), "installer.exe")
- if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
- t.Fatal(err)
- }
- if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
- release()
- t.Fatal("installer with the wrong SHA-256 received an execution claim")
- }
-}
diff --git a/desktop/cmd/update-helper/release_unit.go b/desktop/cmd/update-helper/release_unit.go
deleted file mode 100644
index 186d8c6e4f..0000000000
--- a/desktop/cmd/update-helper/release_unit.go
+++ /dev/null
@@ -1,238 +0,0 @@
-package main
-
-import (
- "fmt"
- "io"
- "os"
- "path/filepath"
- "sort"
- "strings"
-
- "reasonix/internal/platform/repair"
- "reasonix/internal/platform/update"
-)
-
-const maxWindowsPayloadMetadataSize = 64 << 10
-
-var verifyWindowsPayloadManifestFn = update.Verify
-
-type stagedFileUpdateMember struct {
- targetPath string
- content []byte
- mode os.FileMode
-}
-
-// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
-// before any live release-unit member is moved. An existing Reasonix.exe is the
-// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
-// installed package that did not have the alias remains unchanged.
-func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
- if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
- return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
- }
- if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
- return nil, fmt.Errorf("load staged release unit: %w", err)
- }
- stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
- if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
- return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
- }
- info, err := os.Lstat(stagingDir)
- if err != nil {
- return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
- }
- if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
- return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
- }
- hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
- if err != nil {
- return nil, fmt.Errorf("load staged release unit: %w", err)
- }
-
- contents := make(map[string][]byte)
- members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
- seenTargets := make(map[string]struct{}, len(claimed.Files))
- for _, file := range claimed.Files {
- targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
- targetKey := strings.ToLower(targetPath)
- if targetPath == "" || targetPath == "." {
- return nil, fmt.Errorf("load staged release unit: target path is invalid")
- }
- if _, ok := seenTargets[targetKey]; ok {
- return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
- }
- seenTargets[targetKey] = struct{}{}
- if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
- continue
- }
-
- sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
- if err != nil {
- return nil, err
- }
- sourcePath := filepath.Join(stagingDir, sourceName)
- content, ok := contents[sourceName]
- if !ok {
- sourceInfo, statErr := os.Lstat(sourcePath)
- if statErr != nil {
- return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
- }
- if !sourceInfo.Mode().IsRegular() {
- return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
- }
- content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
- if err != nil {
- return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
- }
- if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
- return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
- }
- contents[sourceName] = content
- }
- members = append(members, stagedFileUpdateMember{
- targetPath: targetPath,
- content: content,
- mode: 0o700,
- })
- }
-
- // Publish the running desktop last. If an earlier member fails, the old
- // desktop remains the executable entry point that can report/retry recovery.
- sort.SliceStable(members, func(i, j int) bool {
- iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
- jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
- return !iPrimary && jPrimary
- })
- return members, nil
-}
-
-func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
- if claimed == nil ||
- !strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
- return fmt.Errorf("claimed release unit primary executable is invalid")
- }
- required := map[string]bool{
- "reasonix-desktop.exe": false,
- "reasonix-guard.exe": false,
- "reasonix-launcher.exe": false,
- "reasonix-update-helper.exe": false,
- "reasonix-cli.exe": false,
- "reasonix.exe": false,
- }
- installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
- for _, file := range claimed.Files {
- target := filepath.Clean(strings.TrimSpace(file.TargetPath))
- if target == "" || target == "." ||
- !strings.EqualFold(filepath.Dir(target), installDir) {
- return fmt.Errorf("claimed release unit target is outside the installation directory")
- }
- name := strings.ToLower(filepath.Base(target))
- seen, ok := required[name]
- if !ok {
- return fmt.Errorf("claimed release unit contains an unexpected target")
- }
- if seen {
- return fmt.Errorf("claimed release unit contains a duplicate target")
- }
- required[name] = true
- }
- for name, seen := range required {
- if !seen {
- return fmt.Errorf("claimed release unit omits %s", name)
- }
- }
- if len(claimed.Files) != len(required) {
- return fmt.Errorf("claimed release unit contains an unexpected target")
- }
- return nil
-}
-
-func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
- manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
- if err != nil {
- return nil, fmt.Errorf("read signed release manifest: %w", err)
- }
- signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
- if err != nil {
- return nil, fmt.Errorf("read signed release manifest signature: %w", err)
- }
- if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
- return nil, fmt.Errorf("verify signed release manifest: %w", err)
- }
- hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
- if err != nil {
- return nil, err
- }
- return hashes, nil
-}
-
-func readWindowsPayloadMetadata(path string) ([]byte, error) {
- pathInfo, err := os.Lstat(path)
- if err != nil {
- return nil, err
- }
- if !pathInfo.Mode().IsRegular() {
- return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
- }
- file, err := os.Open(path)
- if err != nil {
- return nil, err
- }
- defer file.Close()
- info, err := file.Stat()
- if err != nil {
- return nil, err
- }
- if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
- return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
- }
- if !os.SameFile(pathInfo, info) {
- return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
- }
- data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
- if err != nil {
- return nil, err
- }
- if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
- return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
- }
- return data, nil
-}
-
-func windowsStagedSourceName(targetBase string) (string, error) {
- switch strings.ToLower(strings.TrimSpace(targetBase)) {
- case "reasonix-desktop.exe":
- return "reasonix-desktop.exe", nil
- case "reasonix-guard.exe":
- return "reasonix-guard.exe", nil
- case "reasonix-launcher.exe":
- return "reasonix-launcher.exe", nil
- case "reasonix-update-helper.exe":
- return "reasonix-update-helper.exe", nil
- case "reasonix-cli.exe":
- return "reasonix-cli.exe", nil
- case "reasonix.exe":
- return "reasonix-launcher.exe", nil
- default:
- return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
- }
-}
-
-func publishLoadedFileUpdateReleaseUnit(
- claimed *repair.UpdateTransaction,
- members []stagedFileUpdateMember,
- publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
-) ([]repair.FileUpdateInstallReceipt, error) {
- if publish == nil || len(members) == 0 {
- return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
- }
- receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
- for _, member := range members {
- receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
- if err != nil {
- return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
- }
- receipts = append(receipts, receipt)
- }
- return receipts, nil
-}
diff --git a/desktop/cmd/update-helper/release_unit_test.go b/desktop/cmd/update-helper/release_unit_test.go
deleted file mode 100644
index ad93487be6..0000000000
--- a/desktop/cmd/update-helper/release_unit_test.go
+++ /dev/null
@@ -1,377 +0,0 @@
-package main
-
-import (
- "errors"
- "os"
- "path/filepath"
- "strings"
- "testing"
-
- "reasonix/internal/base/tempdir"
- "reasonix/internal/platform/repair"
- "reasonix/internal/platform/update"
-)
-
-func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
- staging := tempdir.New(t)
- for name, content := range map[string]string{
- "reasonix-desktop.exe": "desktop-v2",
- "reasonix-guard.exe": "guard-v2",
- "reasonix-launcher.exe": "launcher-v2",
- "reasonix-update-helper.exe": "helper-v2",
- "reasonix-cli.exe": "cli-v2",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- useTestWindowsPayloadManifest(t, staging, "v2")
- installDir := tempdir.New(t)
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: "v2",
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- Files: []repair.UpdateTransactionFile{
- {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
- {TargetPath: filepath.Join(installDir, "Reasonix.exe")},
- },
- }
-
- members, err := loadWindowsStagedReleaseUnit(claimed, staging)
- if err != nil {
- t.Fatal(err)
- }
- if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
- t.Fatalf("last published member = %q, want desktop", got)
- }
- var published []string
- receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
- published = append(published, filepath.Base(target)+"="+string(content))
- return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
- })
- if err != nil {
- t.Fatal(err)
- }
- if len(receipts) != len(members) {
- t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
- }
- if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
- t.Fatalf("portable alias did not reuse launcher payload: %s", got)
- }
- if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
- t.Fatalf("publish order = %v", published)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
- staging := tempdir.New(t)
- if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
- t.Fatal(err)
- }
- useTestWindowsPayloadManifest(t, staging, "v2")
- installDir := tempdir.New(t)
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: "v2",
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- Files: []repair.UpdateTransactionFile{
- {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
- },
- }
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
- t.Fatal("incomplete staged release unit was accepted")
- }
-}
-
-func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
- _, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
- for i, file := range complete.Files {
- t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
- claimed := *complete
- claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
- claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
- if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
- !strings.Contains(err.Error(), "omits") {
- t.Fatalf("missing target error = %v", err)
- }
- })
- }
- t.Run("extra", func(t *testing.T) {
- claimed := *complete
- claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
- repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
- if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
- !strings.Contains(err.Error(), "unexpected") {
- t.Fatalf("extra target error = %v", err)
- }
- })
- t.Run("duplicate", func(t *testing.T) {
- claimed := *complete
- claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
- if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
- !strings.Contains(err.Error(), "duplicate") {
- t.Fatalf("duplicate target error = %v", err)
- }
- })
- t.Run("outside", func(t *testing.T) {
- claimed := *complete
- claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
- claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
- if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
- !strings.Contains(err.Error(), "outside") {
- t.Fatalf("outside target error = %v", err)
- }
- })
- t.Run("primary", func(t *testing.T) {
- claimed := *complete
- claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
- if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
- !strings.Contains(err.Error(), "primary") {
- t.Fatalf("primary target error = %v", err)
- }
- })
-}
-
-func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
- staging := tempdir.New(t)
- for _, name := range update.WindowsPayloadFileNames() {
- content := "payload:" + name
- if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- useTestWindowsPayloadManifest(t, staging, "v2")
- installDir := tempdir.New(t)
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: "v2",
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- Files: []repair.UpdateTransactionFile{
- {TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
- {TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
- {TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
- },
- }
-
- members, err := loadWindowsStagedReleaseUnit(claimed, staging)
- if err != nil {
- t.Fatal(err)
- }
- for _, member := range members {
- if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
- t.Fatalf("missing portable alias was added to publish set: %+v", members)
- }
- }
-}
-
-func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
- claimed := &repair.UpdateTransaction{TargetKind: "file"}
- members := []stagedFileUpdateMember{
- {targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
- {targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
- }
- var published []string
- receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
- published = append(published, target)
- return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
- })
- if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
- t.Fatalf("publish error = %v", err)
- }
- if len(published) != 1 {
- t.Fatalf("published members = %v, want one attempted member", published)
- }
- if len(receipts) != 0 {
- t.Fatalf("failed first publish returned receipts: %+v", receipts)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
- staging := tempdir.New(t)
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-guard.exe",
- "reasonix-launcher.exe",
- "reasonix-update-helper.exe",
- "reasonix-cli.exe",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- writeWindowsPayloadManifestForTest(t, staging, "v2")
- acceptWindowsPayloadManifestForTest(t)
- _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- original := readVerifiedWindowsStagedPayloadFn
- readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
- return nil, errors.New("payload signature rejected")
- }
- t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
-
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
- t.Fatal("unsigned staged payload must be rejected before publication")
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
- staging := tempdir.New(t)
- for name, content := range map[string]string{
- "reasonix-desktop.exe": "desktop-v2",
- "reasonix-guard.exe": "guard-v2",
- "reasonix-launcher.exe": "launcher-v2",
- "reasonix-update-helper.exe": "helper-v2",
- "reasonix-cli.exe": "cli-v2",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- writeWindowsPayloadManifestForTest(t, staging, "v2")
- acceptWindowsPayloadManifestForTest(t)
- _, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- var verified []string
- original := readVerifiedWindowsStagedPayloadFn
- readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
- verified = append(verified, filepath.Base(path))
- return os.ReadFile(path)
- }
- t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
-
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
- t.Fatal(err)
- }
- if len(verified) != len(update.WindowsPayloadFileNames()) {
- t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
- staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- useUnverifiedPayloadReaderForTest(t)
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
- !strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
- t.Fatalf("missing signed manifest error = %v", err)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
- staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- writeWindowsPayloadManifestForTest(t, staging, "v2")
- useUnverifiedPayloadReaderForTest(t)
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
- !strings.Contains(err.Error(), "verify signed release manifest") {
- t.Fatalf("bad signed manifest error = %v", err)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
- staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- writeWindowsPayloadManifestForTest(t, staging, "v3")
- acceptWindowsPayloadManifestForTest(t)
- useUnverifiedPayloadReaderForTest(t)
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
- !strings.Contains(err.Error(), "identity does not match") {
- t.Fatalf("release version drift error = %v", err)
- }
-}
-
-func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
- staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
- writeWindowsPayloadManifestForTest(t, staging, "v2")
- acceptWindowsPayloadManifestForTest(t)
- useUnverifiedPayloadReaderForTest(t)
- if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
- t.Fatal(err)
- }
- if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
- !strings.Contains(err.Error(), "does not match the signed release manifest") {
- t.Fatalf("release member drift error = %v", err)
- }
-}
-
-func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
- t.Helper()
- staging := tempdir.New(t)
- for _, name := range update.WindowsPayloadFileNames() {
- if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- installDir := tempdir.New(t)
- files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
- for _, name := range update.WindowsPayloadFileNames() {
- files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
- }
- files = append(files, repair.UpdateTransactionFile{
- TargetPath: filepath.Join(installDir, "Reasonix.exe"),
- MissingBefore: true,
- })
- return staging, &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: version,
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- Files: files,
- }
-}
-
-func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
- t.Helper()
- hashes := make(map[string]string)
- for _, name := range update.WindowsPayloadFileNames() {
- content, err := os.ReadFile(filepath.Join(staging, name))
- if os.IsNotExist(err) {
- content = []byte("missing:" + name)
- } else if err != nil {
- t.Fatal(err)
- }
- hashes[name] = update.WindowsPayloadSHA256(content)
- }
- manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
- if err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
- t.Fatal(err)
- }
-}
-
-func acceptWindowsPayloadManifestForTest(t *testing.T) {
- t.Helper()
- original := verifyWindowsPayloadManifestFn
- verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
- if string(signature) != "test signature" {
- return errors.New("unexpected test signature")
- }
- return nil
- }
- t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
-}
-
-func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
- t.Helper()
- writeWindowsPayloadManifestForTest(t, staging, version)
- acceptWindowsPayloadManifestForTest(t)
- useUnverifiedPayloadReaderForTest(t)
-}
-
-func useUnverifiedPayloadReaderForTest(t *testing.T) {
- t.Helper()
- original := readVerifiedWindowsStagedPayloadFn
- readVerifiedWindowsStagedPayloadFn = os.ReadFile
- t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
-}
diff --git a/desktop/cmd/update-helper/versioned_windows.go b/desktop/cmd/update-helper/versioned_windows.go
deleted file mode 100644
index a9d65a4ea0..0000000000
--- a/desktop/cmd/update-helper/versioned_windows.go
+++ /dev/null
@@ -1,118 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "fmt"
- "os"
- "path/filepath"
- "strings"
-
- "reasonix/internal/platform/installlayout"
- "reasonix/internal/platform/repair"
-)
-
-// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
-// staged NSIS payload:
-//
-// InstallRoot/
-// reasonix-launcher.exe
-// Reasonix.exe (launcher alias when present or portable)
-// reasonix-cli.exe (CLI entry; full binary for now)
-// current.json
-// versions//
-// reasonix-desktop.exe
-// reasonix-cli.exe
-// reasonix-update-helper.exe
-//
-// Any failure before the current.json pointer swap leaves the previous active
-// version unchanged. The helper never counts crashes or selects prior versions.
-func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
- if claimed == nil {
- return fmt.Errorf("versioned activate: transaction is nil")
- }
- installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
- // When the claimed primary is already under versions//, climb to root.
- if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
- installRoot = root
- }
- version := strings.TrimSpace(claimed.ToVersion)
- if err := installlayout.ValidateVersionName(version); err != nil {
- // Accept bare product versions from NSIS (1.20.0 → v1.20.0).
- if !strings.HasPrefix(version, "v") {
- version = "v" + version
- }
- if err := installlayout.ValidateVersionName(version); err != nil {
- return fmt.Errorf("versioned activate: %w", err)
- }
- }
- stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
-
- desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
- cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
- helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
- launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
- for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
- info, err := os.Lstat(path)
- if err != nil {
- return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
- }
- if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
- return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
- }
- }
-
- requestID := repair.UpdateTransactionID(claimed)
- if requestID == "" {
- requestID = "helper-" + version
- }
- if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
- InstallRoot: installRoot,
- Version: version,
- RequestID: requestID,
- Members: []installlayout.Member{
- {Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
- {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
- {Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
- },
- RootMembers: []installlayout.Member{
- {Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
- {Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
- {Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
- },
- RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
- }); err != nil {
- return err
- }
-
- // Remove flat release-unit leftovers so the install root is the thin layout.
- // Do not remove the launcher/CLI/alias we just wrote.
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-guard.exe",
- "reasonix-update-helper.exe", // helper lives only under versions/
- } {
- _ = os.Remove(filepath.Join(installRoot, name))
- }
- // Best-effort retention GC of older version trees.
- _ = installlayout.RetainPreviousVersions(installRoot, 0)
- _ = installlayout.CleanupStaleStaging(installRoot, 0)
- return nil
-}
-
-// preferVersionedWindowsActivation reports whether the staged payload is
-// complete enough for versioned-v1 activation.
-func preferVersionedWindowsActivation(stagingDir string) bool {
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-cli.exe",
- "reasonix-update-helper.exe",
- "reasonix-launcher.exe",
- } {
- info, err := os.Lstat(filepath.Join(stagingDir, name))
- if err != nil || !info.Mode().IsRegular() {
- return false
- }
- }
- return true
-}
diff --git a/desktop/cmd/update-helper/versioned_windows_test.go b/desktop/cmd/update-helper/versioned_windows_test.go
deleted file mode 100644
index 70e05639f9..0000000000
--- a/desktop/cmd/update-helper/versioned_windows_test.go
+++ /dev/null
@@ -1,131 +0,0 @@
-//go:build windows
-
-package main
-
-import (
- "os"
- "path/filepath"
- "testing"
-
- "reasonix/internal/platform/installlayout"
- "reasonix/internal/platform/repair"
-
- "reasonix/internal/base/tempdir"
-)
-
-func TestPreferVersionedWindowsActivation(t *testing.T) {
- staging := tempdir.New(t)
- if preferVersionedWindowsActivation(staging) {
- t.Fatal("empty staging must not prefer versioned")
- }
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-cli.exe",
- "reasonix-update-helper.exe",
- "reasonix-launcher.exe",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- if !preferVersionedWindowsActivation(staging) {
- t.Fatal("complete staging must prefer versioned")
- }
-}
-
-func TestActivateVersionedWindowsFromStaging(t *testing.T) {
- installDir := tempdir.New(t)
- staging := tempdir.New(t)
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-cli.exe",
- "reasonix-update-helper.exe",
- "reasonix-launcher.exe",
- "reasonix-guard.exe",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- // Seed a flat desktop so cleanup is observable.
- if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
- t.Fatal(err)
- }
- if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
- t.Fatal(err)
- }
-
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: "v1.20.0",
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- CreatedAt: "2026-01-01T00:00:00Z",
- }
- if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
- t.Fatal(err)
- }
- if !installlayout.HasCurrent(installDir) {
- t.Fatal("current.json missing")
- }
- ptr, err := installlayout.ReadCurrent(installDir)
- if err != nil || ptr.ActiveVersion != "v1.20.0" {
- t.Fatalf("pointer=%+v err=%v", ptr, err)
- }
- desktop, err := installlayout.ActiveDesktopPath(installDir)
- if err != nil {
- t.Fatal(err)
- }
- raw, err := os.ReadFile(desktop)
- if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
- t.Fatalf("desktop payload = %q err=%v", raw, err)
- }
- for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
- if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
- t.Fatalf("root entry %s: %v", name, err)
- }
- }
- if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
- t.Fatal("flat desktop should be removed")
- }
- if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
- t.Fatal("flat guard should be removed")
- }
- if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
- t.Fatalf("prefer relaunch = %s", prefer)
- }
-}
-
-func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
- installDir := tempdir.New(t)
- staging := tempdir.New(t)
- for _, name := range []string{
- "reasonix-desktop.exe",
- "reasonix-cli.exe",
- "reasonix-update-helper.exe",
- "reasonix-launcher.exe",
- "reasonix-guard.exe",
- } {
- if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
- t.Fatal(err)
- }
- }
- // Minimal claimed unit (versioned path does not need full flat file list).
- claimed := &repair.UpdateTransaction{
- SchemaVersion: 1,
- ToVersion: "v1.20.0",
- TargetKind: "file",
- TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
- CreatedAt: "2026-01-01T00:00:00Z",
- }
- started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
- if err != nil {
- t.Fatal(err)
- }
- if !started || receipts != nil {
- t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
- }
- if !installlayout.HasCurrent(installDir) {
- t.Fatal("versioned activation did not write current.json")
- }
-}
diff --git a/desktop/electron/arch.json b/desktop/electron/arch.json
index 02c03acb85..deb3f5d6f4 100644
--- a/desktop/electron/arch.json
+++ b/desktop/electron/arch.json
@@ -4,5 +4,14 @@
"x64": "amd64",
"arm64": "arm64",
"universal": "universal"
+ },
+ "_fpm": "fpm spells the architecture its own way in an .rpm name (x86_64, aarch64) wherever artifactName uses ${arch}; the deb comes out as amd64 already. Each spelling is renamed to the release arch, and one not listed here fails the build.",
+ "fpm": {
+ "x64": [
+ "x86_64"
+ ],
+ "arm64": [
+ "aarch64"
+ ]
}
}
diff --git a/desktop/electron/electron-builder.yml b/desktop/electron/electron-builder.yml
index b3fc038a27..9a7c0d5a98 100644
--- a/desktop/electron/electron-builder.yml
+++ b/desktop/electron/electron-builder.yml
@@ -96,6 +96,7 @@ mac:
linux:
target:
- deb
+ - rpm
icon: assets/icon.png
category: Development
maintainer: esengine
@@ -129,3 +130,22 @@ deb:
- root
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
+
+rpm:
+ # The same package name as the deb, so a distribution's package database
+ # names one product under one identifier.
+ packageName: reasonix-studio
+ # electron-builder's own script removes the command link whatever $1 says, and
+ # rpm runs it for the old package after the new one installed its link.
+ afterRemove: ../packaging/linux/rpm-after-remove.sh
+ # The same two system-path files as the deb, for the same reasons, with the
+ # owner stated for the same reason: the rpm spec records whoever ran the build
+ # unless told, and polkit will not execute a helper its invoker could have
+ # written.
+ fpm:
+ - --rpm-user
+ - root
+ - --rpm-group
+ - root
+ - linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
+ - ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
diff --git a/desktop/electron/package.json b/desktop/electron/package.json
index 1bdd54917e..35e8fd72f7 100644
--- a/desktop/electron/package.json
+++ b/desktop/electron/package.json
@@ -16,12 +16,13 @@
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"freeze-live": "electron test/freeze_live.js",
+ "windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
- "electron": "44.5.1",
+ "electron": "44.7.0",
"electron-builder": "^26.15.3",
- "playwright": "1.63.0"
+ "playwright": "1.64.0"
},
"pnpm": {
"onlyBuiltDependencies": [
diff --git a/desktop/electron/packaging/arch.js b/desktop/electron/packaging/arch.js
index 0568c04631..669903760c 100644
--- a/desktop/electron/packaging/arch.js
+++ b/desktop/electron/packaging/arch.js
@@ -1,6 +1,6 @@
"use strict";
const path = require("node:path");
-const { map } = require("../arch.json");
+const { map, fpm } = require("../arch.json");
// What each artifact was built for, keyed by the path electron-builder wrote.
// Taken from the Arch enum it hands the hook rather than read back out of the
@@ -26,4 +26,9 @@ function canonical(arch) {
return to;
}
-module.exports = { built, record, canonical, map };
+// Every spelling electron-builder or fpm may put in a file name for arch.
+function spellings(arch) {
+ return [arch, ...(fpm[arch] ?? [])];
+}
+
+module.exports = { built, record, canonical, spellings, map, fpm };
diff --git a/desktop/electron/packaging/canonical-artifact-names.js b/desktop/electron/packaging/canonical-artifact-names.js
index 5f8c2f76de..501f48955c 100644
--- a/desktop/electron/packaging/canonical-artifact-names.js
+++ b/desktop/electron/packaging/canonical-artifact-names.js
@@ -1,12 +1,21 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
-const { built, canonical, map } = require("./arch");
+const { built, canonical, spellings, map, fpm } = require("./arch");
// The archs whose electron-builder spelling differs from the release one. A
// name still carrying one of these got past the rename, which is the failure
// this refuses to ship rather than report.
-const foreign = Object.keys(map).filter((from) => map[from] !== from);
+const foreign = [
+ ...Object.keys(map).filter((from) => map[from] !== from),
+ ...Object.values(fpm).flat(),
+];
+
+// names reports whether a file name carries arch as a whole segment, which is
+// how both the rename and the leftover check recognise it.
+function names(name, arch) {
+ return name.includes(`-${arch}.`) || name.includes(`-${arch}-`);
+}
// plan records the rename of one artifact and of everything written beside it
// under its name — a .blockmap left behind points at a file that no longer
@@ -38,7 +47,9 @@ module.exports = async function canonicalArtifactNames(buildResult) {
const arch = built.get(path.resolve(file));
if (!arch) continue;
const to = canonical(arch);
- if (to !== arch) plan(path.resolve(file), arch, to, renames);
+ const name = path.basename(file);
+ const from = spellings(arch).find((s) => names(name, s));
+ if (from && from !== to) plan(path.resolve(file), from, to, renames);
}
for (const [from, to] of renames) fs.renameSync(from, to);
@@ -53,7 +64,7 @@ module.exports = async function canonicalArtifactNames(buildResult) {
}
for (const file of out) {
const name = path.basename(file);
- const stray = foreign.find((a) => name.includes(`-${a}.`) || name.includes(`-${a}-`));
+ const stray = foreign.find((a) => names(name, a));
if (stray) {
throw new Error(`${name} still names ${stray}; studio-manifest reads artifact names as GOARCH`);
}
diff --git a/desktop/electron/pnpm-lock.yaml b/desktop/electron/pnpm-lock.yaml
index 545ad7c3e1..a854071a79 100644
--- a/desktop/electron/pnpm-lock.yaml
+++ b/desktop/electron/pnpm-lock.yaml
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
- specifier: 44.5.1
- version: 44.5.1(supports-color@7.2.0)
+ specifier: 44.7.0
+ version: 44.7.0(supports-color@7.2.0)
electron-builder:
specifier: ^26.15.3
version: 26.15.3(electron-builder-squirrel-windows@26.15.3)(supports-color@7.2.0)
playwright:
- specifier: 1.63.0
- version: 1.63.0
+ specifier: 1.64.0
+ version: 1.64.0
packages:
@@ -374,8 +374,8 @@ packages:
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
engines: {node: '>=8.0.0'}
- electron@44.5.1:
- resolution: {integrity: sha512-lx7AYoFIiyvEvgvpyWO9kOC60xtNPpiPZAth6Fmg9NutzpCAMLwpUlaBY2EXfZyIgUdzEmLMj9KvK0Vrokl1yQ==}
+ electron@44.7.0:
+ resolution: {integrity: sha512-aUq9/zLAAFYc+a9gy6cd0C3Y/N/RmovLCD8TV8LhIzNhuND6HEgsu96jJz9cbAxvckSPv+Trf2PKEjktoZgFkw==}
engines: {node: '>= 22.12.0'}
hasBin: true
@@ -762,13 +762,13 @@ packages:
resolution: {integrity: sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==}
engines: {node: '>=16.0.0'}
- playwright-core@1.63.0:
- resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ playwright-core@1.64.0:
+ resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
- playwright@1.63.0:
- resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ playwright@1.64.0:
+ resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
engines: {node: '>=20'}
hasBin: true
@@ -1551,7 +1551,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- electron@44.5.1(supports-color@7.2.0):
+ electron@44.7.0(supports-color@7.2.0):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0(supports-color@7.2.0)
@@ -1696,7 +1696,7 @@ snapshots:
es6-error: 4.1.1
matcher: 3.0.0
roarr: 2.15.4
- semver: 7.8.5
+ semver: 7.7.4
serialize-error: 7.0.1
optional: true
@@ -1883,11 +1883,11 @@ snapshots:
node-abi@4.35.0:
dependencies:
- semver: 7.8.5
+ semver: 7.7.4
node-api-version@0.2.1:
dependencies:
- semver: 7.8.5
+ semver: 7.7.4
node-gyp@12.4.0:
dependencies:
@@ -1896,7 +1896,7 @@ snapshots:
graceful-fs: 4.2.11
nopt: 9.0.0
proc-log: 6.1.0
- semver: 7.8.5
+ semver: 7.7.4
tar: 7.5.22
tinyglobby: 0.2.17
undici: 6.28.0
@@ -1942,11 +1942,11 @@ snapshots:
pvutils: 1.2.0
tslib: 2.8.1
- playwright-core@1.63.0: {}
+ playwright-core@1.64.0: {}
- playwright@1.63.0:
+ playwright@1.64.0:
dependencies:
- playwright-core: 1.63.0
+ playwright-core: 1.64.0
plist@3.1.0:
dependencies:
diff --git a/desktop/electron/src/acts.js b/desktop/electron/src/acts.js
new file mode 100644
index 0000000000..2123f813d7
--- /dev/null
+++ b/desktop/electron/src/acts.js
@@ -0,0 +1,21 @@
+"use strict";
+
+// The acts the kernel asks of the application, because only this process can do
+// them. relaunch and quit are a handover and arrive in that order, so arming
+// the restart before ending is the sequence rather than a coincidence; show
+// brings the window forward for a clicked toast.
+//
+// Nothing is answered. Each is performed by ending or by showing, so an
+// acknowledgement would have to come from a process on its way out.
+function actHandler({ app, showWindow }) {
+ return (act) => {
+ if (act === "relaunch") {
+ app.relaunch();
+ return;
+ }
+ if (act === "quit") app.quit();
+ if (act === "show") showWindow();
+ };
+}
+
+module.exports = { actHandler };
diff --git a/desktop/electron/src/browserviews.js b/desktop/electron/src/browserviews.js
index b1d9e21a75..481a0b85ab 100644
--- a/desktop/electron/src/browserviews.js
+++ b/desktop/electron/src/browserviews.js
@@ -152,6 +152,10 @@ class BrowserViews {
if (id !== targetId) this.putAway(other.view, other);
}
this.shown = targetId;
+ // A page that was parked comes back as a view that is attached but whose
+ // widget never repaints until the window's geometry changes; attaching it
+ // again is what wakes it, the same as after a minimize.
+ if (!entry.at) this.win.contentView.removeChildView(entry.view);
this.win.contentView.addChildView(entry.view);
entry.view.setBounds(at);
entry.at = at;
diff --git a/desktop/electron/src/graphics.js b/desktop/electron/src/graphics.js
new file mode 100644
index 0000000000..c3420a5ed5
--- /dev/null
+++ b/desktop/electron/src/graphics.js
@@ -0,0 +1,35 @@
+"use strict";
+
+const PREF_KEY = "rx-hw-accel";
+
+// A launch-only override for a window that will not draw: the environment
+// variable or the flag, neither of which is written back to the preferences.
+function shouldDisableGpu({ prefs, env, argv }) {
+ return accelerationOff(prefs) || env?.REASONIX_DISABLE_GPU === "1" || (argv ?? []).includes("--disable-gpu");
+}
+
+// Chromium reads this before the app is ready, so a saved "off" only reaches
+// the launch after the one that saved it.
+function accelerationOff(prefs) {
+ return prefs?.[PREF_KEY] === "off";
+}
+
+// launchedOff is what this launch applied (the saved choice or a launch-only
+// override); savedOff is the saved choice alone. compositing is Electron's raw
+// gpu_compositing string, which it documents as usable only after
+// gpu-info-update and does not enumerate, so it is reported, never interpreted.
+function graphicsReport(app, { launchedOff, savedOff }) {
+ let compositing = "";
+ try {
+ compositing = String(app.getGPUFeatureStatus().gpu_compositing ?? "");
+ } catch {
+ // Not answerable yet; the page shows only what was applied.
+ }
+ return { launchedOff, savedOff, compositing };
+}
+
+function graphicsHandler(app, fromWindow, launch) {
+ return (event) => (fromWindow(event) ? graphicsReport(app, launch) : null);
+}
+
+module.exports = { graphicsHandler, PREF_KEY, accelerationOff, shouldDisableGpu, graphicsReport };
diff --git a/desktop/electron/src/main.js b/desktop/electron/src/main.js
index dbb3bb9a48..8918d27b02 100644
--- a/desktop/electron/src/main.js
+++ b/desktop/electron/src/main.js
@@ -17,7 +17,7 @@ const { uiLanguage } = require("./uilang");
const { installFullScreenKey } = require("./fullscreen");
const { installReload } = require("./reload");
const { externalTarget } = require("./links");
-const { reveal, revealWorkspace } = require("./reveal");
+const { reveal, revealWorkspace, revealLogs } = require("./reveal");
const { appIcon } = require("./appicon");
const layout = require("./layout");
const { offerCleanup } = require("./legacy");
@@ -27,8 +27,11 @@ const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
+const { stateFile, openWindow } = require("./windowstate");
+const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
+const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -63,6 +66,7 @@ const hostBinary = layout.hostBinary(where);
const computerHelper = layout.computerHelper(where);
const pageDir = layout.pageDir(where);
+const acts = actHandler({ app, showWindow: () => showWindow() });
let kernel = null;
let win = null;
let origin = "";
@@ -126,7 +130,7 @@ async function launchKernel(args) {
app.quit();
}
},
- onAct: handOver,
+ onAct: (act) => acts(act),
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
@@ -309,28 +313,34 @@ function createWindow() {
const mac = process.platform === "darwin";
const windows = process.platform === "win32";
const chrome = mac || windows;
- return new BrowserWindow({
- ...fitted(),
- ...appIcon(),
- minWidth: MIN_SIZE.width,
- minHeight: MIN_SIZE.height,
- // Shown once it has been measured against the screen it landed on; sizing a
- // visible window makes the correction a flicker.
- show: false,
- backgroundColor: ground(),
- frame: !windows,
- titleBarStyle: mac ? "hiddenInset" : "default",
- ...(mac ? { trafficLightPosition: LIGHTS } : {}),
- webPreferences: {
- preload: path.join(__dirname, "preload.js"),
- // Stated rather than left to the defaults: this is the list a reviewer
- // reads to know what the renderer may reach.
- nodeIntegration: false,
- contextIsolation: true,
- sandbox: true,
- webviewTag: false,
- webSecurity: true,
- additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
+ return openWindow({
+ BrowserWindow,
+ screen,
+ file: stateFile(app.getPath("userData")),
+ fallback: fitted(),
+ min: MIN_SIZE,
+ options: {
+ ...appIcon(),
+ minWidth: MIN_SIZE.width,
+ minHeight: MIN_SIZE.height,
+ // Shown once it has been measured against the screen it landed on; sizing a
+ // visible window makes the correction a flicker.
+ show: false,
+ backgroundColor: ground(),
+ frame: !windows,
+ titleBarStyle: mac ? "hiddenInset" : "default",
+ ...(mac ? { trafficLightPosition: LIGHTS } : {}),
+ webPreferences: {
+ preload: path.join(__dirname, "preload.js"),
+ // Stated rather than left to the defaults: this is the list a reviewer
+ // reads to know what the renderer may reach.
+ nodeIntegration: false,
+ contextIsolation: true,
+ sandbox: true,
+ webviewTag: false,
+ webSecurity: true,
+ additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
+ },
},
});
}
@@ -363,6 +373,8 @@ nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
});
+ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
+
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
});
@@ -403,6 +415,9 @@ ipcMain.handle("shell:open-external", (event, raw) => {
ipcMain.handle("shell:reveal-workspace", (event, root) =>
fromWindow(event) && client ? revealWorkspace(client, shell, String(root)) : { code: "", error: "no window" },
);
+ipcMain.handle("shell:reveal-logs", (event) =>
+ fromWindow(event) ? revealLogs(shell, logs?.dir) : { error: "no window" },
+);
ipcMain.handle("shell:reveal", (event, base, rel) =>
fromWindow(event) && client ? reveal(client, shell, String(base), String(rel)) : { code: "", error: "no window" },
);
@@ -451,6 +466,11 @@ app.setName("Reasonix Studio");
// already running owns those session files.
const identity = instanceID(hostBinary);
app.setPath("userData", profileFor(app.getPath("userData"), identity));
+// Chromium decides on the GPU before the app is ready, so this is the last
+// point a saved choice can still reach it.
+const savedPrefs = loadPrefs(prefsFile(app.getPath("userData")));
+const gpuLaunch = { launchedOff: shouldDisableGpu({ prefs: savedPrefs, env: process.env, argv: process.argv }), savedOff: accelerationOff(savedPrefs) };
+if (gpuLaunch.launchedOff) app.disableHardwareAcceleration();
// A launch from a shortcut has no console, so this file is the only place a
// failure before the window can be read back from.
logs = openLogs(app.getPath("userData"));
@@ -509,21 +529,6 @@ async function hostLastWords() {
return logs.host.tail();
}
-// The acts a handover asks of the application. The kernel decides when: it has
-// downloaded and staged a replacement, and what is left is the part only this
-// process can do. They arrive in this order, so arming the restart before
-// ending is the sequence rather than a coincidence.
-//
-// Nothing is answered. Both are performed by ending, so an acknowledgement
-// would have to come from a process on its way out.
-function handOver(act) {
- if (act === "relaunch") {
- app.relaunch();
- return;
- }
- if (act === "quit") app.quit();
-}
-
// Before the handshake the only window is the starting one, and the launch
// decides for itself whether to go on.
app.on("window-all-closed", () => {
diff --git a/desktop/electron/src/preload.js b/desktop/electron/src/preload.js
index c7657b2532..aa871e7fce 100644
--- a/desktop/electron/src/preload.js
+++ b/desktop/electron/src/preload.js
@@ -25,11 +25,14 @@ contextBridge.exposeInMainWorld("reasonixHost", {
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
closeWindow: () => ipcRenderer.invoke("window:close"),
+ // What the shell's graphics are running as this launch.
+ graphics: () => ipcRenderer.invoke("shell:graphics"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
// A project the sidebar lists, which may have no pane at all.
revealWorkspace: (root) => ipcRenderer.invoke("shell:reveal-workspace", String(root)),
+ revealLogs: () => ipcRenderer.invoke("shell:reveal-logs"),
// Where a dropped file lives. Resolved here rather than in the page: the
// renderer is handed a File and never a path, and a turn that has to work on
// the file itself cannot do it on a copy of the bytes.
diff --git a/desktop/electron/src/reveal.js b/desktop/electron/src/reveal.js
index 0f905dd0ca..eabbc17121 100644
--- a/desktop/electron/src/reveal.js
+++ b/desktop/electron/src/reveal.js
@@ -67,4 +67,13 @@ async function revealWorkspace(client, shell, root, platform = process.platform)
return null;
}
-module.exports = { reveal, revealWorkspace };
+// The shell's own logs folder. The shell names it, never the page, and it is
+// opened rather than selected: it is a directory this app created. null means
+// it was shown.
+async function revealLogs(shell, dir) {
+ if (!dir) return { error: "the logs folder is unknown" };
+ const failed = await shell.openPath(String(dir));
+ return failed ? { error: failed } : null;
+}
+
+module.exports = { reveal, revealWorkspace, revealLogs };
diff --git a/desktop/electron/src/tray.js b/desktop/electron/src/tray.js
index 05e67223c3..e423da1893 100644
--- a/desktop/electron/src/tray.js
+++ b/desktop/electron/src/tray.js
@@ -1,5 +1,4 @@
"use strict";
-const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
// How often the fold is re-read. It is a projection the kernel recomputes on
@@ -10,18 +9,25 @@ const FOLD_INTERVAL_MS = 5000;
// installTray brings the icon up and reports whether there is one. A shell with
// no icon must never background its window: hiding it would leave a running
// session with nothing left to bring it back.
-function installTray(host, { onOpen, onQuit }) {
+function installTray(host, { onOpen, onQuit }, electron = require("electron")) {
+ const { Menu, Tray, nativeImage, screen } = electron;
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
- const paint = () => {
+ // The menu is rebuilt only when what it says changed. Linux hands the menu to
+ // the desktop's indicator over D-Bus, and replacing it every poll closes one
+ // the person has open or leaves the icon without one.
+ let painted = "";
+ const paint = (force = false) => {
// The sentence and the menu words come from the kernel with the numbers:
// the language is the desktop's setting, and one surface should not need a
// second catalogue to say what the first already said.
const line = state.fold?.line ?? "";
const words = state.fold?.labels ?? {};
+ const shown = JSON.stringify([line, words, !!state.prefs?.closeToTray]);
+ if (!force && shown === painted) return;
icon.setToolTip(line ? `Reasonix Studio — ${line}` : "Reasonix Studio");
icon.setContextMenu(
Menu.buildFromTemplate([
@@ -37,13 +43,16 @@ function installTray(host, { onOpen, onQuit }) {
// switch has one implementation whichever control asked.
const next = await host.setTrayPrefs(true, !state.prefs?.closeToTray);
if (next) state.prefs = next;
- paint();
+ // The native checkbox has already flipped itself; redraw from state
+ // even when nothing changed, so a refused save puts it back.
+ paint(true);
},
},
{ type: "separator" },
{ label: words.quit ?? "Quit", click: onQuit },
]),
);
+ painted = shown;
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
diff --git a/desktop/electron/src/windowstate.js b/desktop/electron/src/windowstate.js
new file mode 100644
index 0000000000..7555192859
--- /dev/null
+++ b/desktop/electron/src/windowstate.js
@@ -0,0 +1,109 @@
+"use strict";
+const fs = require("node:fs");
+const path = require("node:path");
+
+// Where the window was when it last closed, so the next launch opens the same
+// rectangle instead of the default. Only a rectangle some display can still
+// show is reused: a monitor that has gone leaves the saved position pointing at
+// nothing, and a frameless window has no title bar to drag back from there.
+const SAVE_DELAY_MS = 400;
+const GRAB = { width: 120, height: 40 };
+
+const stateFile = (userData) => path.join(userData, "window-state.json");
+
+const whole = (v) => Number.isFinite(v) && Number.isInteger(v);
+
+function loadState(file) {
+ try {
+ const raw = JSON.parse(fs.readFileSync(file, "utf8"));
+ if (!raw || typeof raw !== "object" || !whole(raw.width) || !whole(raw.height) || raw.width <= 0 || raw.height <= 0) return null;
+ const placed = whole(raw.x) && whole(raw.y);
+ return { ...(placed ? { x: raw.x, y: raw.y } : {}), width: raw.width, height: raw.height, maximized: raw.maximized === true };
+ } catch {
+ return null;
+ }
+}
+
+// Written beside and renamed over, so a crash mid-write leaves the last good copy.
+function saveState(file, state) {
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify(state));
+ fs.renameSync(tmp, file);
+}
+
+// The strip a person grabs the window by must lie inside some display's work area.
+function grabbable(saved, areas) {
+ if (saved.x === undefined) return null;
+ return (
+ areas.find((a) => {
+ const across = Math.min(saved.x + saved.width, a.x + a.width) - Math.max(saved.x, a.x);
+ return across >= Math.min(GRAB.width, saved.width) && saved.y >= a.y && saved.y + GRAB.height <= a.y + a.height;
+ }) ?? null
+ );
+}
+
+/** placement answers the rectangle to open at. The size is cut to the display
+ * that shows the saved position, and the position is moved in until the whole
+ * window fits; a rectangle nothing shows keeps only its size, cut to the
+ * primary display, and the platform picks the place. The layout's minimum
+ * comes last. */
+function placement({ saved, areas, primary, fallback, min }) {
+ if (!saved) return { ...fallback, maximized: false };
+ const home = grabbable(saved, areas);
+ const area = home ?? primary;
+ const width = Math.max(min.width, Math.min(saved.width, area.width));
+ const height = Math.max(min.height, Math.min(saved.height, area.height));
+ if (!home) return { width, height, maximized: saved.maximized };
+ const within = (v, lo, span, size) => Math.max(lo, Math.min(v, lo + span - size));
+ return { x: within(saved.x, area.x, area.width, width), y: within(saved.y, area.y, area.height, height), width, height, maximized: saved.maximized };
+}
+
+// A minimised or full-screen window reports the screen's rectangle as its normal
+// bounds on some platforms, so what it says then is not what to come back to.
+const settled = (win) => !win.isMinimized() && !win.isFullScreen();
+
+const capture = (win) => ({ ...win.getNormalBounds(), maximized: win.isMaximized() });
+
+/** track writes the window's rectangle shortly after it settles and once more as
+ * it closes. A minimised or full-screen window keeps its normal bounds. */
+function track(win, file, { delay = SAVE_DELAY_MS, timer = setTimeout, untimer = clearTimeout } = {}) {
+ let pending = null;
+ const write = () => {
+ pending = null;
+ if (win.isDestroyed() || !settled(win)) return;
+ try {
+ saveState(file, capture(win));
+ } catch {
+ // A rectangle that did not save is one launch at the default size, not a reason to fail the window.
+ }
+ };
+ const later = () => {
+ if (pending) untimer(pending);
+ pending = timer(write, delay);
+ };
+ for (const event of ["resize", "move", "maximize", "unmaximize"]) win.on(event, later);
+ win.on("close", () => {
+ if (pending) untimer(pending);
+ write();
+ });
+}
+
+/** openWindow builds the window at its remembered rectangle and keeps that
+ * record current. It is hidden until the caller shows it, so a maximise has
+ * nothing to flicker against. */
+function openWindow({ BrowserWindow, screen, file, fallback, min, options }) {
+ const { maximized, ...rect } = placement({
+ saved: loadState(file),
+ areas: screen.getAllDisplays().map((d) => d.workArea),
+ primary: screen.getPrimaryDisplay().workArea,
+ fallback,
+ min,
+ });
+ const win = new BrowserWindow({ ...rect, ...options });
+ if (maximized) win.maximize();
+ track(win, file);
+ return win;
+}
+
+module.exports = { stateFile, loadState, saveState, placement, track, openWindow, GRAB };
diff --git a/desktop/electron/test/unit.mjs b/desktop/electron/test/unit.mjs
index 2e31a38ddc..834c326140 100644
--- a/desktop/electron/test/unit.mjs
+++ b/desktop/electron/test/unit.mjs
@@ -205,7 +205,7 @@ test("an unreachable kernel is an answer, not a crash", async () => {
assert.equal(await dead.trayState(), null);
});
-const { reveal, revealWorkspace } = require("../src/reveal.js");
+const { reveal, revealWorkspace, revealLogs } = require("../src/reveal.js");
// A kernel that answers /workspace/locate as the test says, and a shell that
// only records what it was asked to open.
@@ -1722,6 +1722,57 @@ test("the window ground follows the stored theme and, for auto, the system", ()
assert.equal(groundFor(undefined, true), GROUND.dark);
});
+const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
+
+test("hardware acceleration is off only when the saved preference says exactly that", () => {
+ assert.equal(GPU_PREF, "rx-hw-accel");
+ assert.equal(accelerationOff({}), false);
+ assert.equal(accelerationOff(undefined), false);
+ assert.equal(accelerationOff({ "rx-hw-accel": "on" }), false);
+ assert.equal(accelerationOff({ "rx-hw-accel": "" }), false);
+ assert.equal(accelerationOff({ "rx-hw-accel": "off" }), true);
+});
+
+test("the graphics report says what this launch applied and passes Electron's status through untouched", () => {
+ const app = (compositing) => ({ getGPUFeatureStatus: () => ({ gpu_compositing: compositing }) });
+ const auto = { launchedOff: false, savedOff: false };
+ for (const value of ["enabled", "enabled_force", "enabled_on", "disabled_software", "unavailable_off", ""]) {
+ assert.deepEqual(graphicsReport(app(value), auto), { ...auto, compositing: value });
+ }
+ assert.deepEqual(graphicsReport(app("disabled_software"), { launchedOff: true, savedOff: true }), { launchedOff: true, savedOff: true, compositing: "disabled_software" });
+ assert.deepEqual(graphicsReport(app("enabled"), { launchedOff: true, savedOff: false }), { launchedOff: true, savedOff: false, compositing: "enabled" });
+ assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => { throw new Error("not ready"); } }, auto), { ...auto, compositing: "" });
+ assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => ({}) }, auto), { ...auto, compositing: "" });
+});
+
+test("the GPU is disabled by the saved preference, the environment or the flag, and only then", () => {
+ const off = (over) => shouldDisableGpu({ prefs: {}, env: {}, argv: [], ...over });
+ assert.equal(off({}), false);
+ assert.equal(off({ prefs: { "rx-hw-accel": "off" } }), true);
+ assert.equal(off({ prefs: { "rx-hw-accel": "on" } }), false);
+ assert.equal(off({ prefs: undefined }), false);
+ assert.equal(off({ env: { REASONIX_DISABLE_GPU: "1" } }), true);
+ assert.equal(off({ env: { REASONIX_DISABLE_GPU: "0" } }), false);
+ assert.equal(off({ argv: ["studio", "--disable-gpu"] }), true);
+ assert.equal(off({ argv: ["--disable-gpu-sandbox"] }), false);
+});
+
+test("a prefs file that is missing or corrupt keeps hardware acceleration", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gpu-"));
+ const file = path.join(dir, "window-prefs.json");
+ assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
+ fs.writeFileSync(file, "{ not json");
+ assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
+});
+
+test("the graphics report is refused to any sender that is not the Studio window", () => {
+ const app = { getGPUFeatureStatus: () => ({ gpu_compositing: "enabled" }) };
+ const win = { id: "studio" };
+ const handle = graphicsHandler(app, (event) => (event.sender === "studio" ? win : null), { launchedOff: true, savedOff: false });
+ assert.deepEqual(handle({ sender: "studio" }), { launchedOff: true, savedOff: false, compositing: "enabled" });
+ assert.equal(handle({ sender: "agent-browser-page" }), null);
+});
+
test("a kernel that dies after its handshake leaves its exit and last words in shell.log and a marker for the next launch", () => {
const { openLogs } = require("../src/shelllog.js");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit } = require("../src/hostexit.js");
@@ -1772,3 +1823,279 @@ test("the notice names where the logs are in both languages and shows how it end
assert.match(en.detail, /code 2/);
assert.match(zh.detail, /2/);
});
+
+test("packaging: an rpm fpm spelled x86_64 leaves the build as the release arch", async () => {
+ const arch = require("../packaging/arch.js");
+ const hook = require("../packaging/canonical-artifact-names.js");
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
+ const deb = path.join(dir, "ReasonixStudio-linux-amd64.deb");
+ const rpm = path.join(dir, "ReasonixStudio-linux-x86_64.rpm");
+ fs.writeFileSync(deb, "d");
+ fs.writeFileSync(rpm, "r");
+ arch.record(deb, "x64");
+ arch.record(rpm, "x64");
+ const out = await hook({ artifactPaths: [deb, rpm] });
+ assert.deepEqual(out.map((f) => path.basename(f)), ["ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm"]);
+ assert.ok(fs.existsSync(path.join(dir, "ReasonixStudio-linux-amd64.rpm")));
+ assert.ok(!fs.existsSync(rpm));
+});
+
+test("packaging: a name still carrying an fpm spelling is refused", async () => {
+ const arch = require("../packaging/arch.js");
+ const hook = require("../packaging/canonical-artifact-names.js");
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
+ const stray = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
+ fs.writeFileSync(stray, "r");
+ arch.record(stray, "x64");
+ await assert.rejects(hook({ artifactPaths: [stray] }), /aarch64/);
+});
+
+test("packaging: a Windows installer named for x64 is renamed to amd64", async () => {
+ const arch = require("../packaging/arch.js");
+ const hook = require("../packaging/canonical-artifact-names.js");
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "win-name-"));
+ const installer = path.join(dir, "ReasonixStudio-windows-x64-installer.exe");
+ const zip = path.join(dir, "ReasonixStudio-windows-x64.zip");
+ for (const f of [installer, zip]) {
+ fs.writeFileSync(f, "x");
+ arch.record(f, "x64");
+ }
+ const out = await hook({ artifactPaths: [installer, zip] });
+ assert.deepEqual(
+ out.map((f) => path.basename(f)),
+ ["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
+ );
+});
+
+const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
+const { EventEmitter } = require("node:events");
+
+const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
+const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
+const MIN = { width: 760, height: 480 };
+const FALLBACK = { width: 1440, height: 900 };
+const open = (saved, areas = [AREA, SECOND]) => placement({ saved, areas, primary: areas[0], fallback: FALLBACK, min: MIN });
+
+test("window state: only a whole rectangle is read back, and it survives a round trip", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
+ const file = stateFile(dir);
+ assert.equal(loadState(file), null);
+ fs.writeFileSync(file, "{not json");
+ assert.equal(loadState(file), null);
+ for (const bad of [{ width: 0, height: 500 }, { width: "900", height: 500 }, { width: 900.5, height: 500 }, [], null]) {
+ fs.writeFileSync(file, JSON.stringify(bad));
+ assert.equal(loadState(file), null, JSON.stringify(bad));
+ }
+ saveState(file, { x: 40, y: 60, width: 1000, height: 700, maximized: true });
+ assert.deepEqual(loadState(file), { x: 40, y: 60, width: 1000, height: 700, maximized: true });
+ fs.writeFileSync(file, JSON.stringify({ width: 1000, height: 700 }));
+ assert.deepEqual(loadState(file), { width: 1000, height: 700, maximized: false });
+});
+
+test("window state: a first launch opens at the default size and a saved rectangle is reopened as it was", () => {
+ assert.deepEqual(open(null), { ...FALLBACK, maximized: false });
+ const saved = { x: 300, y: 120, width: 1100, height: 760, maximized: false };
+ assert.deepEqual(open(saved), saved);
+ assert.deepEqual(open({ ...saved, x: 2100, y: 100 }), { ...saved, x: 2100, y: 100 });
+});
+
+test("window state: a rectangle no display shows keeps its size and loses its place", () => {
+ const gone = { x: 2400, y: 100, width: 1100, height: 760, maximized: false };
+ assert.deepEqual(open(gone, [AREA]), { width: 1100, height: 760, maximized: false });
+ const above = { x: 300, y: -500, width: 1100, height: 760, maximized: false };
+ assert.deepEqual(open(above, [AREA]), { width: 1100, height: 760, maximized: false });
+ const huge = { x: 5000, y: 0, width: 4000, height: 3000, maximized: true };
+ assert.deepEqual(open(huge, [AREA]), { width: 1920, height: 1040, maximized: true });
+});
+
+test("window state: a saved size is raised to the layout's floor", () => {
+ const small = open({ x: 10, y: 10, width: 300, height: 200, maximized: false });
+ assert.deepEqual([small.width, small.height], [760, 480]);
+});
+
+function fakeWindow(normal) {
+ const win = new EventEmitter();
+ Object.assign(win, { maximized: false, destroyed: false, normal });
+ win.getNormalBounds = () => win.normal;
+ win.isMaximized = () => win.maximized;
+ win.isDestroyed = () => win.destroyed;
+ win.minimized = false;
+ win.fullscreen = false;
+ win.isMinimized = () => win.minimized;
+ win.isFullScreen = () => win.fullscreen;
+ return win;
+}
+
+test("window state: moves are written once they settle, a close is written at once, and a maximised window keeps its normal bounds", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
+ const file = stateFile(dir);
+ const timers = [];
+ const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
+ track(win, file, { timer: (fn) => timers.push(fn) - 1, untimer: (id) => (timers[id] = () => {}) });
+ win.emit("resize");
+ win.emit("move");
+ win.emit("resize");
+ assert.equal(loadState(file), null);
+ timers.at(-1)();
+ assert.deepEqual(loadState(file), { x: 10, y: 20, width: 900, height: 600, maximized: false });
+ win.normal = { x: 50, y: 60, width: 1000, height: 650 };
+ win.maximized = true;
+ win.emit("close");
+ assert.deepEqual(loadState(file), { x: 50, y: 60, width: 1000, height: 650, maximized: true });
+ win.destroyed = true;
+ win.normal = { x: 1, y: 1, width: 5, height: 5 };
+ win.emit("move");
+ timers.at(-1)();
+ assert.equal(loadState(file).width, 1000);
+});
+
+test("window state: a minimised or full-screen close keeps the last settled record", () => {
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
+ const file = stateFile(dir);
+ const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
+ track(win, file, { timer: () => 0, untimer: () => {} });
+ win.emit("close");
+ const kept = loadState(file);
+ win.normal = { x: 0, y: 0, width: 1920, height: 1040 };
+ win.minimized = true;
+ win.emit("close");
+ assert.deepEqual(loadState(file), kept);
+ win.minimized = false;
+ win.fullscreen = true;
+ win.emit("close");
+ assert.deepEqual(loadState(file), kept);
+});
+
+test("window state: a saved size is cut to the display it opens on and the window is moved in", () => {
+ const big = { x: 1700, y: 600, width: 1400, height: 900, maximized: false };
+ const out = open(big, [AREA]);
+ assert.deepEqual([out.width, out.height], [1400, 900].map((v, i) => Math.min(v, [1920, 1040][i])));
+ assert.ok(out.x + out.width <= 1920 && out.y + out.height <= 1040, JSON.stringify(out));
+ const small = { x: 100, y: 100, width: 1800, height: 1000, maximized: false };
+ const cut = open(small, [{ x: 0, y: 0, width: 1366, height: 728 }]);
+ assert.deepEqual(cut, { x: 0, y: 0, width: 1366, height: 728, maximized: false });
+});
+
+test("kernel acts: show brings the window forward, relaunch arms the restart, quit ends, anything else is ignored", () => {
+ const { actHandler } = require("../src/acts.js");
+ const calls = [];
+ const handle = actHandler({
+ app: { relaunch: () => calls.push("relaunch"), quit: () => calls.push("quit") },
+ showWindow: () => calls.push("show"),
+ });
+ for (const act of ["show", "relaunch", "quit", "unknown"]) handle(act);
+ assert.deepEqual(calls, ["show", "relaunch", "quit"]);
+});
+
+test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
+ const { installTray } = require("../src/tray.js");
+ const menus = [];
+ let tips = 0;
+ class FakeTray {
+ on() {}
+ setImage() {}
+ setToolTip() { tips++; }
+ setContextMenu(menu) { menus.push(menu); }
+ destroy() {}
+ }
+ const electron = {
+ Menu: { buildFromTemplate: (template) => ({ template }) },
+ Tray: FakeTray,
+ nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
+ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
+ };
+ let fold = { line: "1 running", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
+ const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => ({ closeToTray: true }) };
+ const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
+ await tray.refresh();
+ await tray.refresh();
+ await tray.refresh();
+ assert.equal(menus.length, 1, "three identical reads rebuilt the menu");
+ assert.equal(tips, 1);
+ fold = { ...fold, line: "2 running" };
+ await tray.refresh();
+ assert.equal(menus.length, 2);
+ assert.equal(menus[1].template.find((item) => item.enabled === false).label, "2 running");
+ tray.close();
+});
+
+test("tray: a refused close-to-tray save redraws the checkbox, and a menu that failed to draw is tried again", async () => {
+ const { installTray } = require("../src/tray.js");
+ const menus = [];
+ let failNext = false;
+ class FakeTray {
+ on() {}
+ setImage() {}
+ setToolTip() {}
+ setContextMenu(menu) { if (failNext) { failNext = false; throw new Error("indicator busy"); } menus.push(menu); }
+ destroy() {}
+ }
+ const electron = {
+ Menu: { buildFromTemplate: (template) => ({ template }) },
+ Tray: FakeTray,
+ nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
+ screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
+ };
+ const fold = { line: "idle", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
+ const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => null };
+ const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
+ failNext = true;
+ await assert.rejects(tray.refresh());
+ await tray.refresh();
+ assert.equal(menus.length, 1, "a menu whose draw threw was never retried");
+ const toggle = menus[0].template.find((item) => item.type === "checkbox");
+ await toggle.click();
+ assert.equal(menus.length, 2, "a refused save left the flipped checkbox on screen");
+ assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
+ tray.close();
+});
+
+test("revealLogs opens only the folder the shell itself names, and reports why it could not", async () => {
+ const opened = [];
+ const shell = { openPath: async (target) => (opened.push(target), "") };
+ assert.equal(await revealLogs(shell, "/user/logs"), null);
+ assert.deepEqual(opened, ["/user/logs"]);
+ const refusing = { openPath: async () => "no application is associated with this folder" };
+ assert.deepEqual(await revealLogs(refusing, "/user/logs"), { error: "no application is associated with this folder" });
+ assert.deepEqual(await revealLogs(shell, ""), { error: "the logs folder is unknown" });
+});
+
+// The built-in browser drew a white page after switching away from a tab and
+// back until the window was resized: a parked view moved onscreen with
+// setBounds alone does not repaint. Bringing it back attaches it again.
+test("a parked page is attached again when it is shown, and a page already on screen is left alone", () => {
+ const Module = require("node:module");
+ const load = Module._load;
+ Module._load = function (request, ...rest) {
+ if (request === "electron") return { WebContentsView: class {}, session: { fromPartition: () => ({}) } };
+ return load.call(this, request, ...rest);
+ };
+ let BrowserViews;
+ try {
+ ({ BrowserViews } = require("../src/browserviews.js"));
+ } finally {
+ Module._load = load;
+ }
+ const calls = [];
+ const win = {
+ on() {},
+ isDestroyed: () => false,
+ getContentBounds: () => ({ width: 1200, height: 800 }),
+ contentView: { addChildView: () => calls.push("add"), removeChildView: () => calls.push("remove") },
+ };
+ const views = new BrowserViews({ win, kernelOrigin: "http://127.0.0.1:1" });
+ const view = { setBounds: (b) => calls.push("bounds " + b.x), webContents: { isDestroyed: () => false } };
+ views.entries.set("t1", { view, at: null });
+ const rect = { x: 10, y: 20, width: 600, height: 400 };
+
+ views.show("t1", rect);
+ assert.deepEqual(calls, ["remove", "add", "bounds 10"]);
+ calls.length = 0;
+ views.show("t1", rect);
+ assert.deepEqual(calls, [], "the same rectangle on a page already shown does no work");
+ views.hide();
+ calls.length = 0;
+ views.show("t1", rect);
+ assert.deepEqual(calls, ["remove", "add", "bounds 10"], "a page put away and shown again is attached again");
+});
+
diff --git a/desktop/electron/test/windowstate_live.js b/desktop/electron/test/windowstate_live.js
new file mode 100644
index 0000000000..d1fdd738de
--- /dev/null
+++ b/desktop/electron/test/windowstate_live.js
@@ -0,0 +1,94 @@
+"use strict";
+// Opens, moves, maximises and closes real windows through the shell's own openWindow, then reopens them: the
+// rectangle a window had when it closed is the one the next launch opens at. Needs no kernel and no network.
+// Run with `pnpm windowstate-live`.
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const { app, BrowserWindow, screen } = require("electron");
+const { stateFile, openWindow } = require("../src/windowstate");
+
+const wait = (ms) => new Promise((r) => setTimeout(r, ms));
+const fails = [];
+const check = (name, ok, got) => {
+ console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
+ if (!ok) fails.push(name);
+};
+
+const file = stateFile(fs.mkdtempSync(path.join(os.tmpdir(), "windowstate-live-")));
+const launch = () =>
+ openWindow({
+ BrowserWindow,
+ screen,
+ file,
+ fallback: { width: 1000, height: 640 },
+ min: { width: 400, height: 300 },
+ options: { show: true, minWidth: 400, minHeight: 300 },
+ });
+const closeOne = async (win) => {
+ win.close();
+ await wait(100);
+};
+
+// Closing the last window would otherwise end the run between launches.
+app.on("window-all-closed", () => {});
+app.whenReady().then(async () => {
+ const first = launch();
+ await wait(300);
+ check("a first launch opens at the default size", first.getBounds().width === 1000 && first.getBounds().height === 640, first.getBounds());
+ const area = screen.getPrimaryDisplay().workArea;
+ const rect = { x: area.x + 60, y: area.y + 50, width: 820, height: 560 };
+ first.setBounds(rect);
+ await wait(700);
+ await closeOne(first);
+ const kept = JSON.parse(fs.readFileSync(file, "utf8"));
+ check("closing records the rectangle", kept.width === rect.width && kept.height === rect.height && kept.x === rect.x && kept.y === rect.y && kept.maximized === false, kept);
+
+ const second = launch();
+ await wait(300);
+ const got = second.getBounds();
+ check("the next launch reopens at that rectangle", got.x === rect.x && got.y === rect.y && got.width === rect.width && got.height === rect.height, got);
+
+ second.maximize();
+ await wait(700);
+ await closeOne(second);
+ const maxed = JSON.parse(fs.readFileSync(file, "utf8"));
+ check("a maximised close keeps the rectangle it will restore to", maxed.maximized === true && maxed.width === rect.width && maxed.height === rect.height, maxed);
+
+ const third = launch();
+ await wait(500);
+ check("the next launch is maximised again", third.isMaximized() === true, third.getBounds());
+ third.unmaximize();
+ await wait(600);
+ const restored = third.getBounds();
+ check("and restores to the rectangle it had before", restored.width === rect.width && restored.height === rect.height, restored);
+ await closeOne(third);
+
+ const fourth = launch();
+ await wait(400);
+ fourth.maximize();
+ await wait(900);
+ fourth.minimize();
+ await wait(900);
+ await closeOne(fourth);
+ const minimised = JSON.parse(fs.readFileSync(file, "utf8"));
+ check("closing while minimised keeps the maximised record and its restore size", minimised.maximized === true && minimised.width === rect.width && minimised.height === rect.height, minimised);
+
+ fs.writeFileSync(file, JSON.stringify({ x: area.x + 40, y: area.y + 40, width: area.width + 800, height: area.height + 600, maximized: false }));
+ const big = launch();
+ await wait(400);
+ const o = big.getBounds();
+ check("a saved size larger than the display is cut to it and kept on screen", o.width <= area.width && o.height <= area.height && o.x >= area.x && o.y >= area.y && o.x + o.width <= area.x + area.width && o.y + o.height <= area.y + area.height, o);
+ await closeOne(big);
+
+ fs.writeFileSync(file, JSON.stringify({ x: area.x + area.width + 5000, y: 10, width: 700, height: 500, maximized: false }));
+ const lost = launch();
+ await wait(300);
+ const b = lost.getBounds();
+ const shown = screen.getAllDisplays().some((d) => b.x >= d.workArea.x && b.x < d.workArea.x + d.workArea.width && b.y >= d.workArea.y);
+ check("a rectangle on a display that is gone opens on one that is there", shown && b.width === 700, b);
+ await closeOne(lost);
+
+ console.log(fails.length ? `${fails.length} failed` : "all checks passed");
+ app.exit(fails.length ? 1 : 0);
+});
diff --git a/desktop/frontend-next/package.json b/desktop/frontend-next/package.json
index 014d761881..7a568e8b35 100644
--- a/desktop/frontend-next/package.json
+++ b/desktop/frontend-next/package.json
@@ -25,13 +25,13 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
- "@codemirror/language": "^6.12.4",
+ "@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
- "@codemirror/view": "^6.43.13",
+ "@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"codemirror": "^6.0.2",
- "katex": "^0.18.10",
+ "katex": "^0.19.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
@@ -44,16 +44,16 @@
"remark-math": "^6.0.0"
},
"devDependencies": {
- "@babel/parser": "^8.0.6",
+ "@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
- "@vitejs/plugin-react": "^6.1.1",
- "jsdom": "^30.1.1",
- "playwright": "^1.63.0",
+ "@vitejs/plugin-react": "^6.1.2",
+ "jsdom": "^30.1.2",
+ "playwright": "^1.64.0",
"typescript": "^7.0.2",
- "vite": "^8.3.2",
+ "vite": "^8.3.3",
"vitest": "^5.0.3"
}
}
diff --git a/desktop/frontend-next/perf/README.md b/desktop/frontend-next/perf/README.md
index 65683c4d67..1cd88fab9c 100644
--- a/desktop/frontend-next/perf/README.md
+++ b/desktop/frontend-next/perf/README.md
@@ -68,6 +68,13 @@ node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
+node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
+node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
+node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
+node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
+node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
+node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
+node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
diff --git a/desktop/frontend-next/perf/cards.mjs b/desktop/frontend-next/perf/cards.mjs
index c27f00de1f..72f59d5d94 100644
--- a/desktop/frontend-next/perf/cards.mjs
+++ b/desktop/frontend-next/perf/cards.mjs
@@ -165,7 +165,7 @@ const edges = () => col.evaluate(() => {
};
});
const setMeasure = (full) => col.evaluate((f) => {
- if (f) document.documentElement.dataset.measure = "full";
+ if (f) document.documentElement.dataset.measure = typeof f === "string" ? f : "full";
else delete document.documentElement.dataset.measure;
}, full);
const composeAt = {};
@@ -197,6 +197,31 @@ check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
+await col.setViewportSize({ width: 1440, height: 900 });
+await setMeasure("full-all");
+await col.waitForTimeout(300);
+const fullAll = await edges();
+for (const width of [1000, 720]) {
+ await col.setViewportSize({ width, height: 900 });
+ await setMeasure("full-all");
+ await col.waitForTimeout(300);
+ const n = await edges();
+ const inside = await col.evaluate(() => {
+ const b = document.querySelector(".compose").getBoundingClientRect();
+ const pane = document.querySelector(".compose").parentElement.getBoundingClientRect();
+ return { left: Math.round(b.left - pane.left), right: Math.round(pane.right - b.right), w: innerWidth };
+ });
+ check(`${width}px 连输入框一起铺满:输入框不超出所在栏`, inside.left >= 0 && inside.right >= 0, `左 ${inside.left},右 ${inside.right}`);
+ check(`${width}px 连输入框一起铺满:输入框与转录左右对齐(容差一个槽)`, Math.abs(n.compose.l - n.flow.l) <= 40 && Math.abs(n.compose.r - n.flow.r) <= 40,
+ `转录 ${n.flow.l}–${n.flow.r},输入框 ${n.compose.l}–${n.compose.r}`);
+}
+await col.setViewportSize({ width: 1440, height: 900 });
+await setMeasure(false);
+check("连输入框一起铺满:输入框与转录同宽", Math.abs(fullAll.compose.l - fullAll.flow.l) <= 40 && Math.abs(fullAll.compose.r - fullAll.flow.r) <= 40,
+ `转录 ${fullAll.flow.l}–${fullAll.flow.r},输入框 ${fullAll.compose.l}–${fullAll.compose.r}`);
+check("连输入框一起铺满:输入框比适宜阅读宽", fullAll.compose.r - fullAll.compose.l > composeAt[1440].r - composeAt[1440].l + 100,
+ `适宜阅读 ${composeAt[1440].r - composeAt[1440].l}px,铺满 ${fullAll.compose.r - fullAll.compose.l}px`);
+
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
diff --git a/desktop/frontend-next/perf/composer.mjs b/desktop/frontend-next/perf/composer.mjs
index 03a27e7f49..198bb8eb0a 100644
--- a/desktop/frontend-next/perf/composer.mjs
+++ b/desktop/frontend-next/perf/composer.mjs
@@ -146,6 +146,49 @@ for (const state of ["idle", "running"]) {
}
check("扫到的对话列覆盖 360–700px", [...swept].some((w) => w <= 360) && [...swept].some((w) => w >= 700), [...swept].sort((a, b) => a - b).slice(0, 1).concat([...swept].sort((a, b) => b - a).slice(0, 1)).join("–"));
+// An untouched phone composer leaves room to read; focus or a draft restores
+// the editing controls without replacing the textarea or its caret.
+const phone = () => page.evaluate(() => {
+ const box = document.querySelector('textarea[role="combobox"]');
+ const shown = (selector) => document.querySelector(selector)?.getBoundingClientRect().height > 0;
+ const tools = [...document.querySelectorAll(".turntools > *")]
+ .filter((el) => el.getBoundingClientRect().height > 0);
+ const rows = new Set(tools.map((el) => {
+ const r = el.getBoundingClientRect();
+ return Math.round((r.top + r.height / 2) / 2);
+ }));
+ return { height: box.getBoundingClientRect().height, line: parseFloat(getComputedStyle(box).lineHeight),
+ refine: shown(".studio-refine"), branch: shown(".studio-branch-pop"), rows: rows.size };
+});
+await page.fill(BOX, "");
+await page.evaluate(() => { localStorage.clear(); sessionStorage.clear(); });
+await page.setViewportSize({ width: 390, height: 760 });
+await page.goto(PAGE, { waitUntil: "networkidle" });
+await page.waitForSelector(BOX);
+await page.click('[role="tab"][aria-selected="true"]');
+await frame();
+const quiet = await phone();
+check("手机:空置输入只占一行", quiet.height <= quiet.line + 1);
+check("手机:空置时收起优化提示,分支提示仍在", !quiet.refine && quiet.branch);
+check("手机:空置工具栏只占一行", quiet.rows === 1);
+await page.click(BOX);
+await frame();
+const focused = await phone();
+check("手机:聚焦恢复编辑空间与优化入口", focused.height > quiet.height && focused.refine);
+await page.fill(BOX, "保留草稿\n第二行\n第三行");
+await page.click('[role="tab"][aria-selected="true"]');
+await frame();
+const draft = await phone();
+check("手机:未聚焦草稿仍展开", draft.height >= draft.line * 3 - 1 && draft.refine);
+check("手机:展开后草稿原样保留", await page.inputValue(BOX) === "保留草稿\n第二行\n第三行");
+await page.fill(BOX, "");
+await page.getByRole("button", { name: "引用到输入框", exact: true }).last().click();
+await page.waitForSelector(".shots");
+await page.click('[role="tab"][aria-selected="true"]');
+await frame();
+const quoted = await phone();
+check("手机:仅有引用附件也保留编辑空间", quoted.height > quiet.height && quoted.refine);
+
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
diff --git a/desktop/frontend-next/perf/dock.mjs b/desktop/frontend-next/perf/dock.mjs
new file mode 100644
index 0000000000..115bbe33b4
--- /dev/null
+++ b/desktop/frontend-next/perf/dock.mjs
@@ -0,0 +1,92 @@
+// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
+// is undocked. Where things start is a layout question, so it is asked of a real browser.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.DOCK_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const geometry = (page) =>
+ page.evaluate(() => {
+ const box = (sel) => {
+ const el = document.querySelector(sel);
+ const r = el?.getBoundingClientRect();
+ return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
+ };
+ return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
+ });
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const [label, width] of [["wide", 1440], ["narrow", 560]]) {
+ const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ const tag = `${scheme}/${label}`;
+ let docked = null;
+ for (const round of ["first open", "after a reload"]) {
+ await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.locator('[data-action="browser.open"]').first().click();
+ await page.waitForTimeout(700);
+ const open = await geometry(page);
+ docked = open;
+ check(`${tag} ${round}: the browser column is drawn`, !!open.side);
+ if (open.side) {
+ if (label === "wide") {
+ check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
+ check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
+ check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
+ } else {
+ check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
+ }
+ }
+ if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
+ }
+ await page.locator('[data-action="browser.open"]').first().click();
+ await page.waitForTimeout(700);
+ const shut = await geometry(page);
+ check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
+ await page.close();
+ }
+}
+// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
+// must still fit and stay clear of the browser column.
+{
+ const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
+ const bad = [];
+ for (let width = 560; width <= 1440; width += 20) {
+ await page.setViewportSize({ width, height: 800 });
+ await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.locator('[data-action="browser.open"]').first().click();
+ await page.waitForTimeout(400);
+ const g = await page.evaluate(() => {
+ const tabs = document.querySelector(".pane > .tabs");
+ const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
+ const t = tabs.getBoundingClientRect();
+ const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
+ const s = side?.getBoundingClientRect();
+ const stacked = !!s && s.top >= t.bottom - 1;
+ const first = tabs.querySelector(".tab")?.getBoundingClientRect();
+ const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
+ const need = last && first ? last.right - first.left + pad : 0;
+ const pane = document.querySelector(".pane").getBoundingClientRect().width;
+ return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
+ });
+ const should = g.pane < g.need + 320 || width <= 640;
+ if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
+ }
+ check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
+ await page.close();
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/firstscreen-fonts.mjs b/desktop/frontend-next/perf/firstscreen-fonts.mjs
new file mode 100644
index 0000000000..9d8906395e
--- /dev/null
+++ b/desktop/frontend-next/perf/firstscreen-fonts.mjs
@@ -0,0 +1,33 @@
+// The first screen is drawn from the core CJK face (about 1.9 MB), not the 7.8 MB full font. The full font is a
+// later family in the stack for characters the core lacks: it must not be fetched for ordinary UI text, and it
+// must still load when a character only it has is shown.
+import { chromium } from "playwright";
+
+const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const browser = await chromium.launch();
+const page = await (await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } })).newPage();
+const fetched = [];
+page.on("response", (r) => {
+ const m = /\/fonts\/(noto-studio[^/?]*)/.exec(r.url());
+ if (m) fetched.push(m[1]);
+});
+await page.goto(PAGE, { waitUntil: "networkidle" });
+await page.waitForSelector(".app", { timeout: 15000 });
+await page.waitForTimeout(1500);
+
+const fails = [];
+if (fetched.includes("noto-studio.woff2")) fails.push("the first screen fetched the full 7.8 MB font");
+if (!fetched.includes("noto-studio-core.woff2")) fails.push("the first screen did not use the core font");
+console.log(fails.length ? `FAIL ${fails.join("; ")} (${fetched.join(", ")})` : ` ok first screen fetched ${fetched.join(", ")} only`);
+
+await page.evaluate(() => {
+ const d = document.createElement("div");
+ d.style.cssText = "position:fixed;left:0;top:0;font:32px var(--ui)";
+ d.textContent = "龘";
+ document.body.append(d);
+});
+await page.waitForTimeout(1500);
+const rare = fetched.includes("noto-studio.woff2");
+console.log(rare ? " ok a character only the full font has still loads it" : "FAIL a rare character did not load the full font");
+await browser.close();
+process.exit(fails.length || !rare ? 1 : 0);
diff --git a/desktop/frontend-next/perf/gutterhit.mjs b/desktop/frontend-next/perf/gutterhit.mjs
new file mode 100644
index 0000000000..1273582ba4
--- /dev/null
+++ b/desktop/frontend-next/perf/gutterhit.mjs
@@ -0,0 +1,119 @@
+// A column's drag handle must not sit on top of the scrollbar beside it: a press on the thumb has to scroll, and a
+// press on the handle has to resize. Hit-testing is a layout question, so it is asked of a real browser.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.GUTTER_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+// The scrollbar is 10px wide against the scroller's right edge, its thumb inset 3px on each side.
+const THUMB = 5;
+const hit = (page, x, y) =>
+ page.evaluate(([x, y]) => {
+ const el = document.elementFromPoint(x, y);
+ const g = el?.closest(".gutter");
+ return { gutter: g ? g.className : null, tag: el ? el.className || el.tagName : null };
+ }, [x, y]);
+
+// Every row of the scroller's right edge, not one sample: a handle that only covers the top or the middle is still a
+// handle on the thumb whenever the thumb is there.
+const sweep = (page, right, top, height) =>
+ page.evaluate(([right, top, height, inset]) => {
+ const hits = [];
+ for (let y = top + 2; y < top + height - 2; y += 12) {
+ for (const dx of inset) {
+ const g = document.elementFromPoint(right - dx, y)?.closest(".gutter");
+ if (g) hits.push(`${g.className.split(" ").at(-1)}@${Math.round(y)}`);
+ }
+ }
+ return hits;
+ }, [right, top, height, [4, 5, 6]]);
+
+const flowBox = (page) =>
+ page.evaluate(() => {
+ const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
+ return { right: r.right, top: r.top, height: r.height };
+ });
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const width of [1280, 1440]) {
+ const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.goto(`${BASE}?pref=en&turns=30`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.evaluate(() => document.fonts.ready);
+ const tag = `${scheme} ${width}px`;
+ const flow = await page.evaluate(() => {
+ const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
+ return { right: r.right, top: r.top, height: r.height };
+ });
+ const dockShut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
+ check(`${tag}: the workbench starts shut`, dockShut === true);
+ const covered = await sweep(page, flow.right, flow.top, flow.height);
+ check(`${tag}: no row of the transcript's scrollbar is under the workbench handle`, covered.length === 0, covered.slice(0, 4).join(" "));
+ await page.mouse.move(flow.right - 5, flow.top + 8);
+ await page.mouse.down();
+ await page.mouse.move(flow.right - 5, flow.top + 160, { steps: 6 });
+ await page.mouse.up();
+ await page.waitForTimeout(250);
+ check(`${tag}: pressing the scrollbar's top and dragging does not open the workbench`, (await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"))) === true);
+
+ const grip = await page.locator(".gutter-r .grip").boundingBox();
+ const pane = await page.evaluate(() => {
+ const r = document.querySelector(".pane").getBoundingClientRect();
+ return { top: r.top, height: r.height };
+ });
+ const mid = pane.top + pane.height / 2;
+ check(`${tag}: the shut handle sits mid-height`, Math.abs(grip.y + grip.height / 2 - mid) <= pane.height * 0.05, `${Math.round(grip.y + grip.height / 2)} against ${Math.round(mid)}`);
+ const onGrip = await hit(page, grip.x + grip.width / 2, grip.y + grip.height / 2);
+ check(`${tag}: the shut workbench still has a handle to press`, onGrip.gutter?.includes("gutter-r") === true, String(onGrip.gutter));
+
+ const rail = await page.evaluate(() => {
+ const el = document.querySelector(".rail .scroll");
+ const r = el?.getBoundingClientRect();
+ return r && r.width > 0 ? { right: r.right, top: r.top, height: r.height } : null;
+ });
+ if (rail) {
+ const edge = await hit(page, rail.right - THUMB, rail.top + rail.height * 0.25);
+ check(`${tag}: the workspace column's scrollbar is not under its handle`, edge.gutter === null, String(edge.gutter));
+ const seam = await hit(page, rail.right + 3, rail.top + rail.height * 0.75);
+ check(`${tag}: the workspace column's seam still resizes`, seam.gutter?.includes("gutter-l") === true, String(seam.gutter));
+ }
+
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-shut-${scheme}-${width}.png` });
+ await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(grip.x - 200, grip.y + grip.height / 2, { steps: 8 });
+ await page.mouse.up();
+ await page.waitForTimeout(300);
+ const shut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
+ check(`${tag}: dragging the handle opens the workbench`, shut === false);
+ const now = await flowBox(page);
+ const openCovered = await sweep(page, now.right, now.top, now.height);
+ check(`${tag}: nor is it once the workbench is open`, openCovered.length === 0, openCovered.slice(0, 4).join(" "));
+ const seamHit = await hit(page, now.right + 2, now.top + now.height * 0.5);
+ check(`${tag}: the open seam still resizes`, seamHit.gutter?.includes("gutter-r") === true, String(seamHit.gutter));
+ const wide = () => page.evaluate(() => document.querySelector('.scroll[data-pane="browser"]').getBoundingClientRect().width);
+ const before = await wide();
+ await page.mouse.move(now.right - 5, now.top + 8);
+ await page.mouse.down();
+ await page.mouse.move(now.right - 5, now.top + 160, { steps: 6 });
+ await page.mouse.up();
+ await page.waitForTimeout(250);
+ check(`${tag}: pressing the scrollbar and dragging leaves the open workbench's width alone`, Math.abs((await wide()) - before) < 1);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-${scheme}-${width}.png` });
+ await page.close();
+ }
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/harness.tsx b/desktop/frontend-next/perf/harness.tsx
index f7ac03c33e..6ed1598ec7 100644
--- a/desktop/frontend-next/perf/harness.tsx
+++ b/desktop/frontend-next/perf/harness.tsx
@@ -3,8 +3,25 @@ import { boot as bootLang } from "../src/i18n";
import { track as trackWidth } from "../src/ui/viewport";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
+import "../src/styles/dock.css";
import "../src/styles/studio.css";
+import "../src/styles/changedpill.css";
+import "../src/styles/chart.css";
+import "../src/styles/community.css";
+import "../src/styles/providerpresets.css";
+import "../src/styles/sessionnotes.css";
+import "../src/styles/sessionquick.css";
+import "../src/styles/sidebardrop.css";
+import "../src/styles/sponsor.css";
+import "../src/styles/versions.css";
+import "../src/styles/phonecomposer.css";
+import "../src/styles/tips.css";
+import "../src/styles/waitnote.css";
+import "../src/styles/narrow.css";
+import "../src/styles/workfold.css";
+import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
+import "../src/styles/themes.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
import { MockPort } from "../src/port/mock";
@@ -78,6 +95,10 @@ class BenchPort extends MockPort {
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
}
+ async foldWorkSettings() {
+ return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
+ }
+
async workspaceGit() {
const git = await super.workspaceGit();
return query.get("git") === "none" ? { ...git, repo: false } : git;
@@ -209,6 +230,11 @@ const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map();
+ private readonly settingsPort = new BenchPort();
+
+ machinePort(): AgentPort {
+ return this.settingsPort;
+ }
portFor(rt: RuntimeView): AgentPort {
let port = this.feeds.get(rt.id);
diff --git a/desktop/frontend-next/perf/intrinsic.mjs b/desktop/frontend-next/perf/intrinsic.mjs
index f5b4d4f2d1..445951b3ff 100644
--- a/desktop/frontend-next/perf/intrinsic.mjs
+++ b/desktop/frontend-next/perf/intrinsic.mjs
@@ -1,85 +1,16 @@
-// The transcript skips layout for off-screen cards with content-visibility,
-// and each one holds an estimated height until it is first laid out. The
-// estimate need not be accurate, but its error must fall on both sides: below
-// the shortest card every unread card is underestimated, the error accumulates
-// in one direction, and the scroller lands short and then corrects — which is
-// the lurch. This measures the real distribution and checks the stylesheet's
-// number against it.
+// A transcript card's height is never an estimate. Rows near the viewport render, and Block unmounts the far ones
+// while holding the height it measured; a card that skipped layout with `content-visibility: auto` held a guess until
+// it first scrolled into view and the page then shrank under the wheel. The stylesheet must not bring that back.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import { chromium } from "playwright";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = process.env.PERF_SRC ?? join(HERE, "..", "src");
-const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=40&pref=zh";
-
-const css = readFileSync(join(SRC, "styles", "app.css"), "utf8");
-const declared = Number(css.match(/contain-intrinsic-size:\s*auto\s+(\d+(?:\.\d+)?)px/)?.[1] ?? NaN);
-if (!Number.isFinite(declared)) {
- console.log("未在样式表中找到 contain-intrinsic-size 的兜底值:该检查将始终通过。");
- process.exit(1);
-}
+const css = ["app.css", "studio.css"].map((f) => readFileSync(join(SRC, "styles", f), "utf8")).join("\n");
const fails = [];
-const check = (name, ok, detail = "") => {
- console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
- if (!ok) fails.push(name);
-};
-
-const browser = await chromium.launch();
-const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" });
-await page.goto(PAGE, { waitUntil: "networkidle" });
-await page.waitForSelector(".compose");
-await page.waitForTimeout(1000);
-
-// Walk the whole transcript so every card lays out at least once: only
-// then is the distribution the one a reader meets.
-const stats = await page.evaluate(async () => {
- const sc = [...document.querySelectorAll("*")]
- .filter((el) => {
- const s = getComputedStyle(el);
- return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 4 && el.querySelector(".call");
- })
- .pop();
- if (sc) {
- for (let y = 0; y <= sc.scrollHeight; y += 400) {
- sc.scrollTop = y;
- await new Promise((r) => requestAnimationFrame(r));
- }
- }
- const hs = [...document.querySelectorAll(".chunk > .enterbox > .call")]
- .map((c) => c.offsetHeight)
- .filter((x) => x > 0)
- .sort((a, b) => a - b);
- if (!hs.length) return null;
- const q = (p) => hs[Math.floor((hs.length - 1) * p)];
- return {
- n: hs.length,
- min: hs[0],
- median: q(0.5),
- mean: Math.round(hs.reduce((a, b) => a + b, 0) / hs.length),
- max: hs[hs.length - 1],
- };
-});
-
-if (!stats) {
- console.log("未量到卡片:该检查将始终通过,请先确认页面存在转录内容。");
- process.exit(1);
-}
-
-console.log(`样式表兜底值 ${declared}px 实测 n=${stats.n} 最小 ${stats.min} 中位数 ${stats.median} 均值 ${stats.mean} 最大 ${stats.max}`);
-check(
- "占位估算不低于最小卡片高度",
- declared >= stats.min,
- declared < stats.min ? `${declared} < ${stats.min}:每张未读卡片均被低估` : "",
-);
-check(
- "占位估算处于实测分布区间内",
- declared <= stats.max,
- declared > stats.max ? `${declared} > ${stats.max}:误差将反向累加` : "",
-);
-
-await browser.close();
-console.log(fails.length ? `\n${fails.length} 项未通过` : "\n全部通过");
+const bad = [...css.matchAll(/([^{}]*\.call[^{}]*)\{([^{}]*)\}/g)].filter((m) => /\.chunk|\.enterbox/.test(m[1]) && /content-visibility\s*:\s*auto/.test(m[2]));
+if (bad.length) fails.push("estimated height");
+console.log(bad.length ? `FAIL ${bad.map((m) => m[1].trim()).join(" | ")} uses content-visibility: auto` : " ok no .call row in the transcript flow estimates its height");
process.exit(fails.length ? 1 : 0);
diff --git a/desktop/frontend-next/perf/narrow.mjs b/desktop/frontend-next/perf/narrow.mjs
new file mode 100644
index 0000000000..8b774ddf2b
--- /dev/null
+++ b/desktop/frontend-next/perf/narrow.mjs
@@ -0,0 +1,133 @@
+// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave
+// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.NARROW_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const probe = (page) =>
+ page.evaluate(() => {
+ const live = (el) => {
+ const r = el.getBoundingClientRect();
+ return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
+ };
+ const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName;
+ const overlaps = (parent) => {
+ const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r);
+ const hits = [];
+ for (let i = 0; i < boxes.length; i++)
+ for (let j = i + 1; j < boxes.length; j++) {
+ const [a, ra] = boxes[i];
+ const [b, rb] = boxes[j];
+ const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
+ const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
+ if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`);
+ }
+ return hits;
+ };
+ const outside = (parent) =>
+ [...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name);
+ const bar = document.querySelector(".chrome");
+ const row = document.querySelector(".compose .row");
+ const tools = document.querySelector(".compose .turntools");
+ const crumb = bar.querySelector(".crumb");
+ const title = crumb.querySelector("b");
+ const clip = crumb.getBoundingClientRect();
+ const t = title.getBoundingClientRect();
+ return {
+ bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
+ row: { overlaps: overlaps(row), outside: outside(row) },
+ toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
+ titleShown: t.width,
+ titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
+ crumbWidth: clip.width,
+ room: document.body.clientWidth,
+ };
+ });
+
+const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
+
+// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
+async function reachable(page) {
+ const more = page.locator(".chrome .r-more");
+ if (await more.isVisible()) {
+ await more.click();
+ await page.waitForTimeout(150);
+ }
+ return page.evaluate((controls) => {
+ const out = [];
+ for (const sel of controls) {
+ const el = document.querySelector(`.chrome ${sel}`);
+ if (!el) {
+ out.push(`${sel} missing`);
+ continue;
+ }
+ const r = el.getBoundingClientRect();
+ const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
+ const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1;
+ if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`);
+ }
+ return out;
+ }, CONTROLS);
+}
+
+const browser = await chromium.launch();
+
+// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor.
+{
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ const bad = [];
+ for (let room = 190; room <= 460; room += 10) {
+ await page.setViewportSize({ width: room, height: 800 });
+ await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.waitForTimeout(200);
+ const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width);
+ if (w < 48) bad.push(`${room}:${Math.round(w)}`);
+ }
+ check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
+ await page.close();
+}
+for (const scheme of ["light", "dark"]) {
+ for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) {
+ const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.evaluate(() => document.fonts.ready);
+ await page.waitForTimeout(500);
+ const tag = `${scheme} ${width}px zoom ${zoom}`;
+ const m = await probe(page);
+ check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
+ check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
+ check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
+ check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
+ check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
+ const hint = await page.evaluate(() => {
+ const el = document.querySelector(".compose textarea");
+ const cs = getComputedStyle(el);
+ const ctx = document.createElement("canvas").getContext("2d");
+ ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
+ const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
+ return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room };
+ });
+ check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint));
+ const reach = await reachable(page);
+ check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", "));
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` });
+ await page.close();
+ }
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/navhint.mjs b/desktop/frontend-next/perf/navhint.mjs
index 82d38460cc..99313e20b6 100644
--- a/desktop/frontend-next/perf/navhint.mjs
+++ b/desktop/frontend-next/perf/navhint.mjs
@@ -1,4 +1,4 @@
-// Icon-rail hover hints: each must be drawn, fully inside the window, and not clipped by any ancestor.
+// Icon-rail hover hints (drawn after a short hover delay): each must be drawn, fully inside the window, and not clipped by any ancestor.
// Clipping is a layout question jsdom cannot answer. Modes are the stored rail choice; "collapsed" also closes the workspace rail.
import { chromium } from "playwright";
@@ -58,7 +58,12 @@ for (const scheme of ["light", "dark"]) {
for (let i = 0; i < count; i++) {
const btn = page.locator(".nav .navbtn").nth(i);
await btn.hover();
- await page.waitForTimeout(300);
+ if (i === 0) {
+ await page.waitForTimeout(120);
+ const early = await measure(page, i);
+ check(`${scheme}/${mode}: the hint is not drawn the instant the pointer arrives`, early && early.opacity < 1, `opacity ${early?.opacity}`);
+ }
+ await page.waitForTimeout(700);
const m = await measure(page, i);
const tag = `${scheme}/${mode}/#${i} ${m?.text ?? "?"}`;
if (!m) { check(`${tag}: hint exists`, false); continue; }
diff --git a/desktop/frontend-next/perf/notice_bar.mjs b/desktop/frontend-next/perf/notice_bar.mjs
new file mode 100644
index 0000000000..e4d27cab4b
--- /dev/null
+++ b/desktop/frontend-next/perf/notice_bar.mjs
@@ -0,0 +1,54 @@
+// A long runtime notice has to be readable whole and dismissible without scrolling.
+// Reads geometry: the bar's sentence is not clipped by .composenotes and its dismiss
+// button lies inside the visible part of it.
+import { chromium } from "playwright";
+
+const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const browser = await chromium.launch();
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+page.on("pageerror", (e) => fails.push("page error: " + e.message));
+await page.goto(`${PAGE}?turns=2`, { waitUntil: "networkidle" });
+await page.waitForSelector(".app", { timeout: 20000 });
+await page.evaluate(() => {
+ window.__feed({
+ kind: "notice",
+ level: "warn",
+ audience: "operator",
+ code: "permission_rules_dormant",
+ text: "1 permission rule matches no tool",
+ detail: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
+ });
+});
+await page.waitForSelector(".rtbar", { timeout: 5000 });
+await page.waitForTimeout(1500);
+
+for (const [w, h] of [[1440, 900], [400, 800], [1100, 640]]) {
+ await page.setViewportSize({ width: w, height: h });
+ await page.waitForTimeout(900);
+ const g = await page.evaluate(() => {
+ const notes = document.querySelector(".composenotes");
+ const nr = notes.getBoundingClientRect();
+ const btn = document.querySelector(".rtbar button").getBoundingClientRect();
+ return {
+ clipped: document.querySelector(".rtbar").getBoundingClientRect().bottom > nr.bottom + 1,
+ btnIn: btn.top >= nr.top - 1 && btn.bottom <= nr.bottom + 1 && btn.right <= innerWidth + 1,
+ sh: notes.scrollHeight, ch: notes.clientHeight,
+ text: document.querySelector(".rtbar .t").textContent,
+ };
+ });
+ // A short window has no room for a long sentence; it scrolls, and the button stays on the first row.
+ if (h >= 720) check(`${w}x${h}: sentence is not clipped`, !g.clipped, `scroll ${g.sh} / client ${g.ch}`);
+ check(`${w}x${h}: dismiss button is visible without scrolling`, g.btnIn);
+ check(`${w}x${h}: no raw placeholder`, !/\{\w+\}/.test(g.text), g.text.slice(0, 30));
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
diff --git a/desktop/frontend-next/perf/reask.mjs b/desktop/frontend-next/perf/reask.mjs
index 576a95c4bd..bf95f36f8b 100644
--- a/desktop/frontend-next/perf/reask.mjs
+++ b/desktop/frontend-next/perf/reask.mjs
@@ -36,9 +36,11 @@ for (const scheme of ["light", "dark"]) {
const g = await page.evaluate(() => {
const el = document.querySelector(".reask textarea");
if (!el) return null;
- return { h: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
+ const row = el.closest(".call").getBoundingClientRect();
+ return { h: el.getBoundingClientRect().height, w: el.getBoundingClientRect().width, row: row.width, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
});
check(`${scheme}/${label}: the rewrite box opened`, !!g);
+ if (g) check(`${scheme}/${label}: the rewrite box spans its row, not the width a bare textarea starts at`, g.w >= g.row * 0.9 - 2, `box ${Math.round(g.w)}px of a ${Math.round(g.row)}px row`);
if (g) {
const room = Math.min(g.scroll, g.view * 0.6, 448);
check(`${scheme}/${label}: long single-line text is visible without scrolling the box`, g.client >= room - 2, `box ${Math.round(g.h)}px, content ${g.scroll}px`);
diff --git a/desktop/frontend-next/perf/runningtool.mjs b/desktop/frontend-next/perf/runningtool.mjs
new file mode 100644
index 0000000000..8caa8b5452
--- /dev/null
+++ b/desktop/frontend-next/perf/runningtool.mjs
@@ -0,0 +1,29 @@
+// A running call with no output yet: its row opens, says it is still running and for how long, and names its own timeout.
+import { chromium } from "playwright";
+const fails = [];
+const check = (name, ok, detail = "") => { console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!ok) fails.push(name); };
+const [,, out, w] = process.argv;
+const b = await chromium.launch();
+const p = await b.newPage({ viewport: { width: +w || 1100, height: 700 } });
+await p.goto((process.env.PERF_URL ?? "http://localhost:4521/perf.html") + "?pref=zh", { waitUntil: "networkidle" }); await p.waitForSelector(".app"); await p.waitForTimeout(400);
+await p.evaluate(async () => {
+ window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "list them" });
+ window.__feed({ kind: "text", text: "我来看一下目录。" });
+ const cmd = 'cd /Users/me/Documents/AI项目/音频搜索 && for f in *.md; do echo "=== $f ($(wc -l < "$f") 行)"; head -20 "$f"; done; ls -la /Users/me/Documents/ ; echo ---; find /Users/me/Documents -maxdepth 3 -name "*.md" | head -50';
+ window.__feed({ kind: "tool_dispatch", tool: { id: "b1", name: "bash", args: JSON.stringify({ command: cmd, timeout_seconds: 900 }) } });
+ await new Promise((r) => setTimeout(r, 300));
+});
+await p.waitForTimeout(600);
+if (out) await p.screenshot({ path: out + "-1.png" });
+const row = p.locator(".activity-group .call").first();
+check("the running row is a disclosure", (await row.locator("details.tool-disclosure").count()) === 1);
+await row.locator("summary").click().catch(() => {});
+await p.waitForTimeout(1500);
+const g = await p.evaluate(() => ({ note: document.querySelector(".activity-call-wait")?.textContent ?? "", input: !!document.querySelector(".activity-call-input"), title: document.querySelector(".activity-group .arg")?.getAttribute("title") ?? "" }));
+check("opened, it shows the whole command", g.input && g.title.includes("maxdepth 3"));
+check("and says it is still running with no output, and for how long", /命令仍在运行/.test(g.note) && /已运行/.test(g.note), g.note);
+check("and names the command's own timeout", /超时上限 900 秒/.test(g.note), g.note);
+if (out) await p.screenshot({ path: out + "-2.png" });
+await b.close();
+console.log(fails.length ? `\n${fails.length} failed` : "\nall ok");
+process.exit(fails.length ? 1 : 0);
diff --git a/desktop/frontend-next/perf/sidebar.mjs b/desktop/frontend-next/perf/sidebar.mjs
new file mode 100644
index 0000000000..2352a95235
--- /dev/null
+++ b/desktop/frontend-next/perf/sidebar.mjs
@@ -0,0 +1,83 @@
+// One sidebar toggle, drawn at one place whatever the sidebar and the icon rail are doing, never under another control;
+// and a run of replies from one model is headed once. Position and hit-testing are questions for a real browser.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.SIDEBAR_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const mode of ["on", "collapsed", "off"]) {
+ const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.addInitScript((m) => localStorage.setItem("rx-nav-rail", m), mode);
+ await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.evaluate(() => document.fonts.ready);
+ const tag = `${scheme}/${mode}`;
+ const spot = async () => {
+ await page.waitForTimeout(700);
+ return page.evaluate(() => {
+ const toggles = [...document.querySelectorAll('[data-action="chrome.rail"]')].filter((b) => b.tabIndex !== -1 && b.getBoundingClientRect().width > 0 && b.getBoundingClientRect().right > 0);
+ const b = toggles[0]?.getBoundingClientRect();
+ if (!b) return { count: toggles.length };
+ const x = b.left + b.width / 2;
+ const y = b.top + b.height / 2;
+ const top = document.elementFromPoint(x, y);
+ return { count: toggles.length, x: Math.round(x), y: Math.round(y), reached: toggles[0].contains(top) };
+ });
+ };
+ const open = await spot();
+ check(`${tag}: exactly one sidebar toggle is drawn while the sidebar is open`, open.count === 1, String(open.count));
+ check(`${tag}: nothing lies over it`, open.reached === true);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
+ await page.locator('[data-action="chrome.rail"]').filter({ visible: true }).first().click({ position: { x: 15, y: 15 } });
+ const shut = await spot();
+ check(`${tag}: exactly one toggle after collapsing`, shut.count === 1, String(shut.count));
+ check(`${tag}: it has not moved`, shut.x === open.x && shut.y === open.y, `${open.x},${open.y} -> ${shut.x},${shut.y}`);
+ check(`${tag}: nothing lies over it once collapsed`, shut.reached === true);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-shut-${tag.replace("/", "-")}.png` });
+ await page.close();
+ }
+}
+for (const scheme of ["light", "dark"]) {
+ const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
+ await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.evaluate(async () => {
+ const say = (text) => {
+ window.__feed({ kind: "text", text });
+ window.__feed({ kind: "message" });
+ };
+ window.__feed({ kind: "turn_started" });
+ say("First reply.");
+ window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
+ window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 5 } });
+ say("Second reply, same model.");
+ say("Third reply, same model.");
+ window.__feed({ kind: "turn_done" });
+ });
+ await page.waitForTimeout(600);
+ const heads = await page.evaluate(() =>
+ [...document.querySelectorAll('.flow .call[data-k="say"]')].map((c) => {
+ const hl = c.querySelector(":scope > .c > .hl");
+ const sym = c.querySelector(":scope > .g");
+ return { quiet: c.hasAttribute("data-quiet"), head: !!hl && getComputedStyle(hl).display !== "none", mark: !!sym && getComputedStyle(sym).display !== "none" };
+ }),
+ );
+ check(`${scheme}: three replies in a turn, one header`, heads.length === 3 && heads.filter((h) => h.head && h.mark).length === 1 && heads[0].head && heads[0].mark, JSON.stringify(heads));
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-replies-${scheme}.png` });
+ await page.close();
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/signals.mjs b/desktop/frontend-next/perf/signals.mjs
new file mode 100644
index 0000000000..d3424023db
--- /dev/null
+++ b/desktop/frontend-next/perf/signals.mjs
@@ -0,0 +1,46 @@
+// The signal rail of the run analysis says what it is and why its marks cannot be moved.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.SIGNALS_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const [label, width] of [["wide", 1280], ["phone", 390]]) {
+ const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.evaluate(() => {
+ window.__feed({ kind: "turn_started" });
+ window.__feed({ kind: "retrying", retryAttempt: 1, retryMax: 2, retryScope: "stream" });
+ window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
+ window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 40 } });
+ window.__feed({ kind: "turn_done" });
+ });
+ await page.locator('[data-action="pane.view"][data-value="analysis"]').click();
+ await page.waitForSelector(".run-signals");
+ const tag = `${scheme}/${label}`;
+ const g = await page.evaluate(() => {
+ const note = document.querySelector(".run-signals .run-section-note");
+ const r = note?.getBoundingClientRect();
+ const box = document.querySelector(".run-signals").getBoundingClientRect();
+ return { text: note?.textContent ?? "", inside: !!r && r.left >= box.left - 1 && r.right <= box.right + 1 && r.width > 0 };
+ });
+ check(`${tag}: the rail says what its marks are and why they stay put`, /位置就是时间|position is their time/.test(g.text), g.text);
+ check(`${tag}: the explanation sits inside the section`, g.inside);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIGNALS_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
+ await page.close();
+ }
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/start.mjs b/desktop/frontend-next/perf/start.mjs
new file mode 100644
index 0000000000..9a611b9b61
--- /dev/null
+++ b/desktop/frontend-next/perf/start.mjs
@@ -0,0 +1,57 @@
+// The workbench tab opens a launcher instead of a browser; the title-bar Browser button still goes straight to one.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.START_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const [label, width] of [["wide", 1440], ["narrow", 900]]) {
+ const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ const tag = `${scheme}/${label}`;
+ const tabs = () => page.evaluate(() => document.querySelectorAll(".workbench-tab").length);
+ await page.locator('[data-action="browser.open"]').first().click();
+ await page.waitForTimeout(700);
+ const direct = await page.evaluate(() => ({ launcher: !!document.querySelector(".workbench-launcher"), tabs: document.querySelectorAll(".workbench-tab").length }));
+ check(`${tag}: the title-bar Browser button goes straight to a browser`, direct.tabs === 1 && !direct.launcher, JSON.stringify(direct));
+ await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await page.locator('[data-action="pane.view"][data-value="browser"]').click();
+ await page.waitForTimeout(700);
+ const start = await page.evaluate(() => {
+ const box = document.querySelector(".workbench-launcher");
+ const r = box?.getBoundingClientRect();
+ return {
+ shown: !!r && r.width > 0,
+ inside: !!r && r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight,
+ choices: [...(box?.querySelectorAll("button") ?? [])].map((b) => b.textContent.trim()),
+ tabs: document.querySelectorAll(".workbench-tab").length,
+ };
+ });
+ check(`${tag}: the Workbench tab opens the launcher`, start.shown, JSON.stringify(start));
+ check(`${tag}: it offers files, changes and the browser`, start.choices.length === 3 && ["Files", "Changes", "Browser"].every((c, i) => start.choices[i].startsWith(c)), start.choices.join(" | "));
+ check(`${tag}: no browser has been opened for the person`, start.tabs === 0, String(start.tabs));
+ check(`${tag}: the launcher is inside the window`, start.inside);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.START_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
+ await page.locator(".workbench-launcher button", { hasText: "Browser" }).click().catch(() => {});
+ await page.waitForTimeout(500);
+ const after = await page.evaluate(() => ({ tabs: document.querySelectorAll(".workbench-tab").length, launcher: !!document.querySelector(".workbench-launcher") }));
+ check(`${tag}: choosing the browser opens one and the launcher steps aside`, after.tabs === 1 && !after.launcher, JSON.stringify(after));
+ await page.close();
+ }
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/perf/wheel.mjs b/desktop/frontend-next/perf/wheel.mjs
index 3dd5d8483a..a297966e5d 100644
--- a/desktop/frontend-next/perf/wheel.mjs
+++ b/desktop/frontend-next/perf/wheel.mjs
@@ -1,10 +1,9 @@
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
//
-// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
-// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
-// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
-// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
+// `.call` 不再带 `content-visibility: auto`:它按预估高度占位、首次进入视口时改成实测高度,
+// scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。块的卸载与占位由 Block 负责。
+// 默认严格;WHEEL_STRICT=0 只报告。
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -12,7 +11,7 @@ const STEP = Number(process.env.WHEEL_STEP ?? 100);
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
-const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
+const STRICT = (process.env.WHEEL_STRICT ?? "1") === "1";
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
diff --git a/desktop/frontend-next/perf/workfold.mjs b/desktop/frontend-next/perf/workfold.mjs
new file mode 100644
index 0000000000..b4065ebd13
--- /dev/null
+++ b/desktop/frontend-next/perf/workfold.mjs
@@ -0,0 +1,132 @@
+// A finished turn's work, folded under one line when the kernel's setting asks for it: the answer stays outside, the
+// line opens on click and keeps its state, a failure shows on the line, and the running turn is never folded.
+import { chromium } from "playwright";
+
+const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
+const SHOTS = process.env.FOLD_SHOTS;
+const fails = [];
+const check = (name, ok, detail = "") => {
+ console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
+ if (!ok) fails.push(name);
+};
+
+const feedTurn = (page, { fail = false, done = true } = {}) =>
+ page.evaluate(async ({ fail, done }) => {
+ const frame = () => new Promise((r) => requestAnimationFrame(r));
+ window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Please fix the failing test" });
+ window.__feed({ kind: "text", text: "I will read the test first." });
+ window.__feed({ kind: "message", text: "I will read the test first." });
+ for (const [i, name] of ["read_file", "bash", "edit_file"].entries()) {
+ const tool = { id: `t${i}`, name, args: JSON.stringify({ path: `pkg/f${i}.go` }) };
+ window.__feed({ kind: "tool_dispatch", tool });
+ window.__feed({ kind: "tool_result", tool: { ...tool, output: fail && i === 1 ? "exit 1" : "ok", durationMs: 120, ...(fail && i === 1 ? { err: "exit status 1" } : {}) } });
+ await frame();
+ }
+ window.__feed({ kind: "text", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
+ window.__feed({ kind: "message", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
+ if (done) window.__feed({ kind: "turn_done" });
+ await frame();
+ }, { fail, done });
+
+const browser = await chromium.launch();
+for (const scheme of ["light", "dark"]) {
+ for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
+ const tag = `${scheme}/${label}`;
+ const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
+ page.setDefaultTimeout(8000);
+ page.on("pageerror", (e) => fails.push("page error: " + e.message));
+ await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await feedTurn(page, { fail: true });
+ await page.waitForTimeout(700);
+ const g = await page.evaluate(() => {
+ const fold = document.querySelector("details.work-fold");
+ const final = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
+ const r = fold?.getBoundingClientRect();
+ return {
+ folds: document.querySelectorAll("details.work-fold").length,
+ open: fold?.open,
+ failed: fold?.hasAttribute("data-failed"),
+ line: fold?.querySelector("summary")?.textContent ?? "",
+ bodyHidden: fold ? fold.querySelector(".work-fold-body").getBoundingClientRect().height === 0 || getComputedStyle(fold.querySelector(".work-fold-body")).display === "none" || !fold.open : null,
+ answerOutside: !!final && !fold?.contains(final) && final.textContent.includes("off-by-one"),
+ inside: !!r && r.left >= 0 && r.right <= innerWidth,
+ };
+ });
+ check(`${tag}: the finished turn has one folded line`, g.folds === 1 && g.open === false, JSON.stringify(g));
+ check(`${tag}: the answer stays outside it`, g.answerOutside);
+ check(`${tag}: the line counts the calls and marks the failure`, /3/.test(g.line) && g.failed === true, g.line);
+ check(`${tag}: the line fits the window`, g.inside);
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-folded-${tag.replace("/", "-")}.png` });
+ await page.locator("details.work-fold > summary").click();
+ await page.waitForTimeout(500);
+ check(`${tag}: a click opens it and the steps show`, await page.evaluate(() => document.querySelector("details.work-fold").open && document.querySelectorAll("details.work-fold .call").length >= 3));
+ if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
+ await page.close();
+ }
+}
+{
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await feedTurn(page, { done: false });
+ await page.waitForTimeout(500);
+ check("a turn still running is never folded", (await page.locator("details.work-fold").count()) === 0);
+ await page.evaluate(() => window.__feed({ kind: "turn_done" }));
+ await page.waitForTimeout(500);
+ check("it folds when the turn settles", (await page.locator("details.work-fold").count()) === 1);
+ await page.close();
+}
+{
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
+ await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await feedTurn(page);
+ await page.waitForTimeout(500);
+ check("with the setting off nothing is folded", (await page.locator("details.work-fold").count()) === 0);
+ await page.close();
+}
+{
+ const page = await browser.newPage({ viewport: { width: 1280, height: 700 } });
+ await page.goto(`${BASE}?pref=en&fold&turns=30`, { waitUntil: "networkidle" });
+ await page.waitForSelector(".app");
+ await feedTurn(page, { done: false });
+ await page.waitForTimeout(600);
+ await page.evaluate(() => { const sc = document.querySelector('[data-pane="flow"]'); sc.scrollTop = sc.scrollHeight; });
+ await page.waitForTimeout(300);
+ const before = await page.evaluate(() => {
+ const sc = document.querySelector('[data-pane="flow"]');
+ const answer = [...document.querySelectorAll('.call[data-k="say"]')].at(-1).getBoundingClientRect();
+ return { top: sc.scrollTop, h: sc.scrollHeight, answerTop: Math.round(answer.top), view: innerHeight };
+ });
+ const frames = await page.evaluate(async () => {
+ const sc = document.querySelector('[data-pane="flow"]');
+ const seen = [];
+ const sample = () => {
+ const a = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
+ const fold = document.querySelector("details.work-fold");
+ seen.push({ folds: fold ? 1 : 0, open: !!fold?.open, answerTop: Math.round(a.getBoundingClientRect().top), top: sc.scrollTop, h: sc.scrollHeight });
+ };
+ window.__feed({ kind: "turn_done" });
+ for (let i = 0; i < 12; i++) {
+ await new Promise((r) => requestAnimationFrame(r));
+ sample();
+ }
+ return seen;
+ });
+ const first = frames[0];
+ const last = frames.at(-1);
+ const answerMoves = new Set(frames.map((f) => f.answerTop)).size;
+ console.log(`settle: before answerTop=${before.answerTop} scrollHeight=${before.h}; first frame ${JSON.stringify(first)}; last ${JSON.stringify(last)}; distinct answer positions over 12 frames: ${answerMoves}`);
+ check("the work is folded in the first frame after the turn ends, never drawn open and then closed", first.folds === 1 && frames.every((f) => !f.open));
+ check("the answer settles in one move rather than wandering", answerMoves <= 2, String(answerMoves));
+ check("the answer is still on screen after the fold", last.answerTop >= 0 && last.answerTop < before.view, String(last.answerTop));
+ check("a reader pinned to the bottom stays there: the page and the scroll position shrink together", Math.abs(last.top + before.view - last.h) < before.view && last.answerTop > 0, JSON.stringify({ top: last.top, h: last.h }));
+ await page.close();
+}
+await browser.close();
+if (fails.length) {
+ console.log(`\n${fails.length} failed`);
+ process.exit(1);
+}
+console.log("\nall ok");
diff --git a/desktop/frontend-next/pnpm-lock.yaml b/desktop/frontend-next/pnpm-lock.yaml
index cf353af4ab..0d618f05ee 100644
--- a/desktop/frontend-next/pnpm-lock.yaml
+++ b/desktop/frontend-next/pnpm-lock.yaml
@@ -48,8 +48,8 @@ importers:
specifier: ^6.1.3
version: 6.1.3
'@codemirror/language':
- specifier: ^6.12.4
- version: 6.12.4
+ specifier: ^6.13.1
+ version: 6.13.1
'@codemirror/search':
specifier: ^6.7.2
version: 6.7.2
@@ -57,8 +57,8 @@ importers:
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
- specifier: ^6.43.13
- version: 6.43.13
+ specifier: ^6.43.14
+ version: 6.43.14
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
@@ -66,8 +66,8 @@ importers:
specifier: ^6.0.2
version: 6.0.2
katex:
- specifier: ^0.18.10
- version: 0.18.10
+ specifier: ^0.19.0
+ version: 0.19.0
react:
specifier: ^19.3.0
version: 19.3.0
@@ -100,8 +100,8 @@ importers:
version: 6.0.0
devDependencies:
'@babel/parser':
- specifier: ^8.0.6
- version: 8.0.6
+ specifier: ^8.0.7
+ version: 8.0.7
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/dom@10.4.1)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)
@@ -115,32 +115,32 @@ importers:
specifier: ^19.3.0
version: 19.3.0(@types/react@19.3.0)
'@vitejs/plugin-react':
- specifier: ^6.1.1
- version: 6.1.1(vite@8.3.2(esbuild@0.28.2))
+ specifier: ^6.1.2
+ version: 6.1.2(vite@8.3.3(esbuild@0.28.2))
jsdom:
- specifier: ^30.1.1
- version: 30.1.1
+ specifier: ^30.1.2
+ version: 30.1.2
playwright:
- specifier: ^1.63.0
- version: 1.63.0
+ specifier: ^1.64.0
+ version: 1.64.0
typescript:
specifier: ^7.0.2
version: 7.0.2
vite:
- specifier: ^8.3.2
- version: 8.3.2(esbuild@0.28.2)
+ specifier: ^8.3.3
+ version: 8.3.3(esbuild@0.28.2)
vitest:
specifier: ^5.0.3
- version: 5.0.3(jsdom@30.1.1)(vite@8.3.2(esbuild@0.28.2))
+ version: 5.0.3(jsdom@30.1.2)(vite@8.3.3(esbuild@0.28.2))
packages:
- '@asamuzakjp/css-color@7.0.1':
- resolution: {integrity: sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==}
+ '@asamuzakjp/css-color@7.1.3':
+ resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
- '@asamuzakjp/dom-selector@9.2.1':
- resolution: {integrity: sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==}
+ '@asamuzakjp/dom-selector@9.2.4':
+ resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@babel/code-frame@7.29.7':
@@ -159,8 +159,8 @@ packages:
resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==}
engines: {node: ^22.18.0 || >=24.11.0}
- '@babel/parser@8.0.6':
- resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==}
+ '@babel/parser@8.0.7':
+ resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==}
engines: {node: ^22.18.0 || >=24.11.0}
hasBin: true
@@ -218,8 +218,8 @@ packages:
'@codemirror/lang-yaml@6.1.3':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
- '@codemirror/language@6.12.4':
- resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
+ '@codemirror/language@6.13.1':
+ resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
'@codemirror/lint@6.9.7':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
@@ -230,43 +230,46 @@ packages:
'@codemirror/state@6.7.6':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
- '@codemirror/view@6.43.13':
- resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
+ '@codemirror/streamparser@6.0.0':
+ resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
- '@csstools/color-helpers@6.1.1':
- resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
+ '@codemirror/view@6.43.14':
+ resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
+
+ '@csstools/color-helpers@6.1.2':
+ resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
engines: {node: '>=20.19.0'}
- '@csstools/css-calc@3.4.0':
- resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==}
+ '@csstools/css-calc@3.4.3':
+ resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==}
engines: {node: '>=20.19.0'}
peerDependencies:
- '@csstools/css-parser-algorithms': ^4.0.0
- '@csstools/css-tokenizer': ^4.0.0
+ '@csstools/css-parser-algorithms': ^4.0.2
+ '@csstools/css-tokenizer': ^4.0.2
- '@csstools/css-color-parser@4.2.3':
- resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==}
+ '@csstools/css-color-parser@4.2.6':
+ resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==}
engines: {node: '>=20.19.0'}
peerDependencies:
- '@csstools/css-parser-algorithms': ^4.0.0
- '@csstools/css-tokenizer': ^4.0.0
+ '@csstools/css-parser-algorithms': ^4.0.2
+ '@csstools/css-tokenizer': ^4.0.2
- '@csstools/css-parser-algorithms@4.0.0':
- resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ '@csstools/css-parser-algorithms@4.0.2':
+ resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==}
engines: {node: '>=20.19.0'}
peerDependencies:
- '@csstools/css-tokenizer': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.2
- '@csstools/css-syntax-patches-for-csstree@1.1.14':
- resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==}
+ '@csstools/css-syntax-patches-for-csstree@1.1.15':
+ resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
- '@csstools/css-tokenizer@4.0.1':
- resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==}
+ '@csstools/css-tokenizer@4.0.2':
+ resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==}
engines: {node: '>=20.19.0'}
'@esbuild/aix-ppc64@0.28.2':
@@ -434,6 +437,15 @@ packages:
'@noble/hashes':
optional: true
+ '@exodus/bytes@1.16.0':
+ resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -783,13 +795,13 @@ packages:
'@ungap/structured-clone@1.4.0':
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
- '@vitejs/plugin-react@6.1.1':
- resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
+ '@vitejs/plugin-react@6.1.2':
+ resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
babel-plugin-react-compiler: ^1.0.0
- oxc-transform-react: ^0.145.0
+ oxc-transform-react: ^0.152.0
vite: ^8.0.0
peerDependenciesMeta:
'@rolldown/plugin-babel':
@@ -877,9 +889,9 @@ packages:
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
- data-urls@7.0.0:
- resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ data-urls@8.0.0:
+ resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
+ engines: {node: ^22.14.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@@ -1038,8 +1050,8 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
- jsdom@30.1.1:
- resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==}
+ jsdom@30.1.2:
+ resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
@@ -1051,8 +1063,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
- katex@0.18.10:
- resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
+ katex@0.19.0:
+ resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==}
hasBin: true
lightningcss-android-arm64@1.33.0:
@@ -1135,10 +1147,6 @@ packages:
lowlight@3.3.0:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
- lru-cache@11.5.2:
- resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
- engines: {node: 20 || >=22}
-
lru-cache@11.5.3:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
@@ -1319,13 +1327,13 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
- playwright-core@1.63.0:
- resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
+ playwright-core@1.64.0:
+ resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
- playwright@1.63.0:
- resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
+ playwright@1.64.0:
+ resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
engines: {node: '>=20'}
hasBin: true
@@ -1457,6 +1465,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
+ tr46@7.0.0:
+ resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -1468,8 +1480,8 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
- undici@8.10.2:
- resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
+ undici@8.11.2:
+ resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
engines: {node: '>=22.19.0'}
unified@11.0.5:
@@ -1505,8 +1517,8 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
- vite@8.3.2:
- resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
+ vite@8.3.3:
+ resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1607,14 +1619,14 @@ packages:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
- whatwg-url@16.0.1:
- resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
- engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
-
whatwg-url@17.1.2:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
+ whatwg-url@17.2.0:
+ resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
+ engines: {node: ^22.14.0 || >=24.0.0}
+
why-is-node-running@3.2.1:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
@@ -1632,15 +1644,15 @@ packages:
snapshots:
- '@asamuzakjp/css-color@7.0.1':
+ '@asamuzakjp/css-color@7.1.3':
dependencies:
- '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-color-parser': 4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-tokenizer': 4.0.1
+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-color-parser': 4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-tokenizer': 4.0.2
lru-cache: 11.5.3
- '@asamuzakjp/dom-selector@9.2.1':
+ '@asamuzakjp/dom-selector@9.2.4':
dependencies:
bidi-js: 1.1.0
css-tree: 3.2.1
@@ -1659,7 +1671,7 @@ snapshots:
'@babel/helper-validator-identifier@8.0.6': {}
- '@babel/parser@8.0.6':
+ '@babel/parser@8.0.7':
dependencies:
'@babel/types': 8.0.6
@@ -1676,22 +1688,22 @@ snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.1':
dependencies:
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@codemirror/lang-css@6.3.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
@@ -1699,7 +1711,7 @@ snapshots:
'@codemirror/lang-go@6.0.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
@@ -1709,60 +1721,60 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
'@lezer/html': 1.3.13
'@codemirror/lang-java@6.0.2':
dependencies:
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@lezer/java': 1.1.4
'@codemirror/lang-javascript@6.2.5':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
'@codemirror/lang-json@6.0.2':
dependencies:
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@lezer/json': 1.0.3
'@codemirror/lang-markdown@6.5.2':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
'@codemirror/lang-python@6.2.1':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
'@codemirror/lang-rust@6.0.2':
dependencies:
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@lezer/rust': 1.0.3
'@codemirror/lang-sql@6.10.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
@@ -1771,26 +1783,27 @@ snapshots:
'@codemirror/lang-xml@6.1.0':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/lang-yaml@6.1.3':
dependencies:
'@codemirror/autocomplete': 6.20.3
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
- '@codemirror/language@6.12.4':
+ '@codemirror/language@6.13.1':
dependencies:
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/streamparser': 6.0.0
+ '@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
@@ -1799,49 +1812,51 @@ snapshots:
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
crelt: 1.0.7
'@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
- '@codemirror/view@6.43.13':
+ '@codemirror/streamparser@6.0.0': {}
+
+ '@codemirror/view@6.43.14':
dependencies:
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
- '@csstools/color-helpers@6.1.1': {}
+ '@csstools/color-helpers@6.1.2': {}
- '@csstools/css-calc@3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)':
+ '@csstools/css-calc@3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
dependencies:
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-tokenizer': 4.0.1
+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-tokenizer': 4.0.2
- '@csstools/css-color-parser@4.2.3(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)':
+ '@csstools/css-color-parser@4.2.6(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)':
dependencies:
- '@csstools/color-helpers': 6.1.1
- '@csstools/css-calc': 3.4.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1))(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.1)
- '@csstools/css-tokenizer': 4.0.1
+ '@csstools/color-helpers': 6.1.2
+ '@csstools/css-calc': 3.4.3(@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2))(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-parser-algorithms': 4.0.2(@csstools/css-tokenizer@4.0.2)
+ '@csstools/css-tokenizer': 4.0.2
- '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.1)':
+ '@csstools/css-parser-algorithms@4.0.2(@csstools/css-tokenizer@4.0.2)':
dependencies:
- '@csstools/css-tokenizer': 4.0.1
+ '@csstools/css-tokenizer': 4.0.2
- '@csstools/css-syntax-patches-for-csstree@1.1.14(css-tree@3.2.1)':
+ '@csstools/css-syntax-patches-for-csstree@1.1.15(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
- '@csstools/css-tokenizer@4.0.1': {}
+ '@csstools/css-tokenizer@4.0.2': {}
'@esbuild/aix-ppc64@0.28.2':
optional: true
@@ -1923,6 +1938,8 @@ snapshots:
'@exodus/bytes@1.15.1': {}
+ '@exodus/bytes@1.16.0': {}
+
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.6.0': {}
@@ -2188,19 +2205,19 @@ snapshots:
'@ungap/structured-clone@1.4.0': {}
- '@vitejs/plugin-react@6.1.1(vite@8.3.2(esbuild@0.28.2))':
+ '@vitejs/plugin-react@6.1.2(vite@8.3.3(esbuild@0.28.2))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.3.2(esbuild@0.28.2)
+ vite: 8.3.3(esbuild@0.28.2)
- '@vitest/mocker@5.0.3(vite@8.3.2(esbuild@0.28.2))':
+ '@vitest/mocker@5.0.3(vite@8.3.3(esbuild@0.28.2))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
- vite: 8.3.2(esbuild@0.28.2)
+ vite: 8.3.3(esbuild@0.28.2)
'@vitest/spy@5.0.3': {}
@@ -2236,11 +2253,11 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.11.1
- '@codemirror/language': 6.12.4
+ '@codemirror/language': 6.13.1
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
- '@codemirror/view': 6.43.13
+ '@codemirror/view': 6.43.14
comma-separated-tokens@2.0.3: {}
@@ -2257,10 +2274,10 @@ snapshots:
csstype@3.2.3: {}
- data-urls@7.0.0:
+ data-urls@8.0.0:
dependencies:
whatwg-mimetype: 5.0.0
- whatwg-url: 16.0.1
+ whatwg-url: 17.1.2
transitivePeerDependencies:
- '@noble/hashes'
@@ -2484,27 +2501,27 @@ snapshots:
js-tokens@4.0.0: {}
- jsdom@30.1.1:
+ jsdom@30.1.2:
dependencies:
- '@asamuzakjp/css-color': 7.0.1
- '@asamuzakjp/dom-selector': 9.2.1
+ '@asamuzakjp/css-color': 7.1.3
+ '@asamuzakjp/dom-selector': 9.2.4
'@bramus/specificity': 2.4.2
- '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1)
- '@exodus/bytes': 1.15.1
+ '@csstools/css-syntax-patches-for-csstree': 1.1.15(css-tree@3.2.1)
+ '@exodus/bytes': 1.16.0
css-tree: 3.2.1
- data-urls: 7.0.0
+ data-urls: 8.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 7.0.0
is-potential-custom-element-name: 1.0.1
- lru-cache: 11.5.2
+ lru-cache: 11.5.3
parse5: 8.0.1
saxes: 6.0.0
tough-cookie: 6.0.2
- undici: 8.10.2
+ undici: 8.11.2
w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
- whatwg-url: 17.1.2
+ whatwg-url: 17.2.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
@@ -2513,7 +2530,7 @@ snapshots:
dependencies:
commander: 8.3.0
- katex@0.18.10:
+ katex@0.19.0:
dependencies:
commander: 15.0.0
@@ -2574,8 +2591,6 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.2
- lru-cache@11.5.2: {}
-
lru-cache@11.5.3: {}
lz-string@1.5.0: {}
@@ -2982,11 +2997,11 @@ snapshots:
picomatch@4.0.7: {}
- playwright-core@1.63.0: {}
+ playwright-core@1.64.0: {}
- playwright@1.63.0:
+ playwright@1.64.0:
dependencies:
- playwright-core: 1.63.0
+ playwright-core: 1.64.0
postcss@8.5.28:
dependencies:
@@ -3182,6 +3197,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ tr46@7.0.0:
+ dependencies:
+ punycode: 2.3.1
+
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -3209,7 +3228,7 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
- undici@8.10.2: {}
+ undici@8.11.2: {}
unified@11.0.5:
dependencies:
@@ -3269,7 +3288,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite@8.3.2(esbuild@0.28.2):
+ vite@8.3.3(esbuild@0.28.2):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -3280,10 +3299,10 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
- vitest@5.0.3(jsdom@30.1.1)(vite@8.3.2(esbuild@0.28.2)):
+ vitest@5.0.3(jsdom@30.1.2)(vite@8.3.3(esbuild@0.28.2)):
dependencies:
'@types/chai': 5.2.3
- '@vitest/mocker': 5.0.3(vite@8.3.2(esbuild@0.28.2))
+ '@vitest/mocker': 5.0.3(vite@8.3.3(esbuild@0.28.2))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3294,10 +3313,10 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
- vite: 8.3.2(esbuild@0.28.2)
+ vite: 8.3.3(esbuild@0.28.2)
why-is-node-running: 3.2.1
optionalDependencies:
- jsdom: 30.1.1
+ jsdom: 30.1.2
transitivePeerDependencies:
- msw
@@ -3313,7 +3332,7 @@ snapshots:
whatwg-mimetype@5.0.0: {}
- whatwg-url@16.0.1:
+ whatwg-url@17.1.2:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
@@ -3321,10 +3340,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
- whatwg-url@17.1.2:
+ whatwg-url@17.2.0:
dependencies:
- '@exodus/bytes': 1.15.1
- tr46: 6.0.0
+ '@exodus/bytes': 1.16.0
+ tr46: 7.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
diff --git a/desktop/frontend-next/public/fonts/noto-studio-core.woff2 b/desktop/frontend-next/public/fonts/noto-studio-core.woff2
new file mode 100644
index 0000000000..f807ee7290
Binary files /dev/null and b/desktop/frontend-next/public/fonts/noto-studio-core.woff2 differ
diff --git a/desktop/frontend-next/scripts/slice-noto-studio.py b/desktop/frontend-next/scripts/slice-noto-studio.py
new file mode 100644
index 0000000000..756b13f586
--- /dev/null
+++ b/desktop/frontend-next/scripts/slice-noto-studio.py
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+"""Cut the core face out of public/fonts/noto-studio.woff2.
+
+The core face carries what a first screen asks for: the common simplified-Chinese
+set shipped as NotoSansSC-subset.woff2, Latin, punctuation and arrows. The full
+font stays as a later family in the stack, so a character the core lacks still
+finds its glyph there and only then pays for the download.
+
+Usage: slice-noto-studio.py
+Needs fontTools and brotli.
+"""
+import sys
+from fontTools import subset
+from fontTools.ttLib import TTFont
+
+COMMON = "NotoSansSC-subset.woff2"
+BLOCKS = [(0x20, 0x24F), (0x2000, 0x206F), (0x2190, 0x23FF),
+ (0x2500, 0x257F), (0x25A0, 0x25FF), (0x2600, 0x27BF), (0x3000, 0x303F), (0xFF00, 0xFFEF)]
+
+
+def main(full_path, core_path):
+ full = set(TTFont(full_path).getBestCmap())
+ common = set(TTFont(full_path.replace("noto-studio.woff2", COMMON)).getBestCmap())
+ core = {p for p in full if p in common or any(a <= p <= b for a, b in BLOCKS)}
+ opts = subset.Options()
+ opts.flavor = "woff2"
+ opts.layout_features = ["*"]
+ opts.notdef_outline = True
+ font = subset.load_font(full_path, opts)
+ s = subset.Subsetter(opts)
+ s.populate(unicodes=sorted(core))
+ s.subset(font)
+ subset.save_font(font, core_path, opts)
+ print(f"core: {len(core)} of {len(full)} characters")
+
+
+if __name__ == "__main__":
+ main(*sys.argv[1:3])
diff --git a/desktop/frontend-next/src/actions.test.ts b/desktop/frontend-next/src/actions.test.ts
index a099c29085..10ae4b32c5 100644
--- a/desktop/frontend-next/src/actions.test.ts
+++ b/desktop/frontend-next/src/actions.test.ts
@@ -145,8 +145,8 @@ describe("what counts as an input a person performs", () => {
expect(c.roots.map((r) => r.kind)).toEqual(["dom-event"]);
});
- it("leaves the environment's own events out", () => {
- const c = only(`export function A() { window.addEventListener("resize", () => {}); return null; }`);
+ it.each(["resize", "pageshow"])("leaves the environment's %s event out", (event) => {
+ const c = only(`export function A() { window.addEventListener("${event}", () => {}); return null; }`);
expect(c.roots).toEqual([]);
expect(c.nonUser.map((n) => n.why)).toEqual(["RUNTIME_EVENT"]);
});
diff --git a/desktop/frontend-next/src/actions.ts b/desktop/frontend-next/src/actions.ts
index d5360d0efa..782079114d 100644
--- a/desktop/frontend-next/src/actions.ts
+++ b/desktop/frontend-next/src/actions.ts
@@ -56,10 +56,16 @@ export const ACTIONS: UIAction[] = [
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
+ { id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
+ { id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
+ { id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
+ { id: "session.selection", kind: "view", target: "optional", proof: "interaction" },
+ { id: "session.batch-archive", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "session.batch-delete", kind: "destructive", target: "none", proof: "authority-effect" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -69,7 +75,11 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.copy-output", kind: "shell-native", target: "none", proof: "interaction" },
// The way out of a window whose assets an update replaced underneath it.
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
+ { id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
+ { id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
+ { id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
+ { id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
@@ -182,6 +192,8 @@ export const ACTIONS: UIAction[] = [
{ id: "community.douyin-qr", kind: "view", target: "none", proof: "interaction" },
{ id: "community.author", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "community.more", kind: "view", target: "none", proof: "interaction" },
+ { id: "sponsor.paypal", kind: "shell-native", target: "none", proof: "interaction" },
+ { id: "sponsor.qr", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.tab", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.category", kind: "view", target: "none", proof: "interaction" },
@@ -194,11 +206,13 @@ export const ACTIONS: UIAction[] = [
{ id: "feedback.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.another", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.thread.more", kind: "view", target: "none", proof: "interaction" },
+ { id: "feedback.thread.toggle", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "feedback.reply.open", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.body", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.reply.send", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "feedback.reply.cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
+ { id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
@@ -241,6 +255,10 @@ export const ACTIONS: UIAction[] = [
{ id: "extensions.finish", kind: "navigation", target: "none", proof: "interaction" },
{ id: "extensions.add", kind: "navigation", target: "none", proof: "interaction" },
{ id: "extensions.pick-folder", kind: "shell-native", target: "none", proof: "interaction" },
+ { id: "folders.pick", kind: "shell-native", target: "none", proof: "interaction" },
+ { id: "folders.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "folders.remove", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
+ { id: "folders.retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.export", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "extensions.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -345,6 +363,7 @@ export const ACTIONS: UIAction[] = [
{ id: "file-revert.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
{ id: "rewind.prepare", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "rewind.commit", kind: "destructive", target: "entity", proof: "authority-effect" },
+ { id: "session.reload", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "rewind.undo", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
@@ -354,6 +373,11 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "provider.preset", kind: "view", target: "entity", proof: "interaction" },
+ { id: "provider.preset-retry", kind: "repeatable", target: "none", proof: "interaction" },
+ { id: "provider.preset-toggle", kind: "view", target: "none", proof: "interaction" },
+ { id: "provider.preset-back", kind: "view", target: "none", proof: "interaction" },
+ { id: "provider.preset-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
@@ -369,6 +393,8 @@ export const ACTIONS: UIAction[] = [
{ id: "compaction.threshold", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Which bound is in force is the setting; how much of it is on screen is not.
{ id: "compaction.advanced", kind: "view", target: "none", proof: "interaction" },
+ { id: "compaction.confirm", kind: "view", target: "none", proof: "interaction" },
+ { id: "compaction.now", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "panel.move", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "panel.delegate-placement", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "context.window-tokens", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -411,12 +437,15 @@ export const ACTIONS: UIAction[] = [
{ id: "notify.turn-done", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "notify.feedback-reply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "notify.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.import", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "theme.folder", kind: "repeatable", target: "none", proof: "interaction" },
+ { id: "theme.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
// Usage changes what report is read, not canonical session state.
{ id: "usage.range.start", kind: "view", target: "none", proof: "interaction" },
@@ -456,8 +485,13 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
+ { id: "workbench.launch-files", kind: "view", target: "none", proof: "interaction" },
+ { id: "workbench.launch-changes", kind: "view", target: "none", proof: "interaction" },
+ { id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
- { id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
+ { id: "workbench.browser-menu", kind: "view", target: "entity", proof: "interaction" },
+ { id: "workbench.browser-close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
+ { id: "workbench.close", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.save", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "workbench.search", kind: "view", target: "none", proof: "interaction" },
@@ -471,6 +505,7 @@ export const ACTIONS: UIAction[] = [
{ id: "palette.pick", kind: "view", target: "entity", proof: "interaction" },
{ id: "palette.search", kind: "view", target: "none", proof: "interaction" },
{ id: "reply.quote", kind: "view", target: "entity", proof: "interaction" },
+ { id: "reply.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.retry", kind: "view", target: "entity", proof: "interaction" },
{ id: "reply.retry-now", kind: "kernel-mutation", target: "entity", proof: "interaction" },
{ id: "reply.configure", kind: "view", target: "none", proof: "interaction" },
@@ -502,9 +537,14 @@ export const ACTIONS: UIAction[] = [
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// Read by the running turn at its next round, so no rebuild stands between the
// click and the canonical change.
+ { id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
+ { id: "auto-archive.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "auto-archive.days", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "ask-timeout.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
+ { id: "ask-timeout.seconds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
diff --git a/desktop/frontend-next/src/assets/alipay.jpg b/desktop/frontend-next/src/assets/alipay.jpg
new file mode 100644
index 0000000000..fa8c60b51a
Binary files /dev/null and b/desktop/frontend-next/src/assets/alipay.jpg differ
diff --git a/desktop/frontend-next/src/assets/wechat-pay.jpg b/desktop/frontend-next/src/assets/wechat-pay.jpg
new file mode 100644
index 0000000000..f6bca2ebe9
Binary files /dev/null and b/desktop/frontend-next/src/assets/wechat-pay.jpg differ
diff --git a/desktop/frontend-next/src/e2e/fixtures/trace_readings.json b/desktop/frontend-next/src/e2e/fixtures/trace_readings.json
index f951758a55..ef73e27ff2 100644
--- a/desktop/frontend-next/src/e2e/fixtures/trace_readings.json
+++ b/desktop/frontend-next/src/e2e/fixtures/trace_readings.json
@@ -523,7 +523,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
],
"replay": [
@@ -631,7 +632,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
]
},
@@ -693,7 +695,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
],
"replay": [
@@ -742,7 +745,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
]
},
@@ -896,7 +900,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
],
"replay": [
@@ -1025,7 +1030,8 @@
},
{
"kind": "turn_done",
- "checkpointTurn": 0
+ "checkpointTurn": 0,
+ "workDurationMs": 1
}
]
}
diff --git a/desktop/frontend-next/src/i18n/en.ts b/desktop/frontend-next/src/i18n/en.ts
index bebc034480..bb2edd0869 100644
--- a/desktop/frontend-next/src/i18n/en.ts
+++ b/desktop/frontend-next/src/i18n/en.ts
@@ -18,12 +18,14 @@ import { EN_COMPOSER } from "./en_composer";
import { EN_BACKUP } from "./en_backup";
import { EN_FEEDBACK } from "./en_feedback";
import { EN_COMMUNITY } from "./en_community";
+import { EN_SPONSOR } from "./en_sponsor";
export const EN: Record = {
"工作台": "Workbench",
"从右侧选择文件,或打开浏览器": "Choose a file on the right, or open a browser",
...EN_SETTINGS,
...EN_COMMUNITY,
+ ...EN_SPONSOR,
...EN_METRICS,
...EN_STORAGE,
...EN_USAGE,
@@ -92,6 +94,7 @@ export const EN: Record = {
"复制这段代码": "Copy this code",
"复制路径": "Copy path",
"思考 {chars}": "Thought · {chars}",
+ "耗时 {t}": "Worked for {t}",
"思考 {secs} 秒 · {chars}": "Thought for {secs}s · {chars}",
"{n} 字": "{n} chars",
"{n} 张图片": "{n} images",
@@ -139,6 +142,10 @@ export const EN: Record = {
"活动": "Activity",
"轨迹": "Trajectory",
"对话": "Conversation",
+ "展开对话": "Expand conversation",
+ "收起对话": "Collapse conversation",
+ "{n} 条回复": "{n} replies",
+ "{n} 条新回复": "{n} new replies",
"事件轨迹": "Event trail",
"运行图": "Run graph",
"你说过的话": "What you said",
@@ -291,7 +298,12 @@ export const EN: Record = {
"新建会话": "Start a new session",
"空会话": "Empty session",
"{n} 轮": "{n} turns",
- "还有 {n} 个 · 全部显示": "{n} more · show all",
+ "{n} / {total} 会话": "{n} / {total} sessions",
+ "显示更多(还有 {n} 个)": "Show more ({n} left)",
+ "还有 {n} 个较早的会话 · 用搜索查找": "{n} older conversations · use search to find them",
+ "还有 {n} 个较早的会话,在那台机器上查看": "{n} older conversations · see them on that machine",
+ "正在加载…": "Loading…",
+ "加载失败 · 重试": "Failed to load · retry",
"打开或新建项目…": "Open or start a project…",
"文件夹的完整路径": "Full path to the folder",
"尚无文件夹": "No folders yet",
@@ -308,6 +320,7 @@ export const EN: Record = {
"移除": "Remove",
"删除": "Delete",
"删除该会话": "Delete this session",
+ "删除主题「{name}」": "Delete theme \"{name}\"",
"删除这份恢复副本?": "Delete this recovery copy?",
"连同其记录一并删除": "Deletes it together with its record",
"恢复副本": "Recovery",
@@ -443,6 +456,17 @@ export const EN: Record = {
"背景模糊、动态天空与颗粒质感全部开启": "Background blur, the animated sky and the grain layer are all on",
"关闭背景模糊、动态天空与颗粒质感,集成显卡或老旧电脑上更省电": "Turns off background blur, the animated sky and the grain layer; lighter on integrated graphics and older machines",
"调整文字的笔画粗细。中文笔画密集,在小字号下加粗有助于提升清晰度。": "Adjusts stroke weight. Dense Chinese glyphs lose definition first at small sizes, and more weight recovers it.",
+ "图形渲染": "Graphics",
+ "随系统": "System default",
+ "仅软件渲染": "Software only",
+ "软件渲染": "Software rendering",
+ "当前启动使用:{mode}": "This launch uses: {mode}",
+ "图形合成状态:{value}": "Graphics compositing status: {value}",
+ "尚未报告图形状态": "No graphics status reported yet",
+ "本次启动已用启动参数关闭硬件加速": "hardware acceleration was switched off for this launch by a launch option",
+ "已按你的设置关闭硬件加速": "hardware acceleration is switched off by your setting",
+ "窗口一直占用显卡、或界面花屏时,先试「视觉效果:节能」,不行再改为「仅软件渲染」:改用 CPU 绘制,显卡不再参与。设置保存后,需退出并重新打开 Studio 才会生效。": "If the window keeps your GPU busy or draws garbage, try Visual effects: Reduced first; if that is not enough, choose Software only: the CPU draws the window and the GPU stays out of it. The choice is saved now and applies after you quit and reopen Studio.",
+ "已保存,退出并重新打开 Studio 后生效": "Saved; quit and reopen Studio for it to apply",
"跟随语言": "Match the language",
"中文界面使用中等字重,西文使用常规字重": "Medium for Chinese, regular for Latin",
"常规": "Regular",
@@ -509,7 +533,9 @@ export const EN: Record = {
"凭据未通过,尚未确认": "Credentials were rejected; availability is still unknown",
"遇到限流,尚未确认": "Rate limited; availability is still unknown",
"网络异常,尚未确认": "Network error; availability is still unknown",
+ "地址跳转到了另一个主机,已拒绝,尚未确认": "The address redirected to another host and was refused; availability is still unknown",
"验证超时,尚未确认": "Verification timed out; availability is still unknown",
+ "本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次": "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly.",
"请求被拒绝,尚未确认": "Request rejected; availability is still unknown",
"尚未确认": "Availability is still unknown",
"读图": "Reads images",
@@ -524,6 +550,11 @@ export const EN: Record = {
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
// 收工回执
"没有未经验证的部分": "nothing left unverified",
+ "等待超时,这个问题已被跳过": "Nobody answered in time; this question was skipped",
+ "有未验证的改动": "Unverified changes",
+ "这一轮自己说明了风险或未验证项": "This turn declared risks or unverified items",
+ "收起交付验收": "Collapse the delivery receipt",
+ "点击查看交付验收:改了什么、哪些没有验证": "Click to see the delivery receipt: what changed and what was not verified",
"未验证": "not verified",
"交付验收": "delivery receipt",
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
@@ -535,15 +566,19 @@ export const EN: Record = {
"在系统文件管理器中显示": "Show in file manager",
"已在 {app} 中打开": "Opened in {app}",
"通知": "Notifications",
- "只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、或者模型反过来问你,都可以让系统提醒一次。": "Only useful while you are not watching: a turn finishing, a stop for approval, or the model asking you something can each raise one system alert.",
+ "只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、模型反过来问你、反馈有了回复,都可以让系统提醒一次。": "Only useful while you are not watching: a turn finishing, a stop for approval, the model asking you something, or a reply to your feedback can each raise one system alert.",
"发送系统通知": "Send system notifications",
- "窗口在后台时由系统弹出提醒。立即生效,正在跑的会话下一次事件就按新设置来": "The system raises the alert while this window is in the background. Immediate: a running session follows the new setting from its next event.",
+ "默认关闭。开启后,无论窗口是否在前台都会由系统弹出提醒;声音由系统决定。立即生效,正在跑的会话下一次事件就按新设置来": "Off by default. When on, the system raises the alert whether or not this window is in front; the sound is whatever the system decides. Immediate: a running session follows the new setting from its next event.",
+ "播放提示音": "Play a sound",
+ "默认关闭。下面选中的情形出现时放一声短提示,不依赖系统通知,也不受系统通知静音的影响。窗口缩小、在后台时也会响": "Off by default. Plays a short chime for the occasions selected below, independent of system notifications and of their muting. It also sounds when the window is minimised or in the background.",
"这一轮结束时": "When the turn ends",
"回答写完、或者这一轮失败了": "The answer is written, or the turn failed",
"有操作等待批准时": "When something waits for approval",
"模型要动的东西超出了已经给出的授权,正停在那里等你": "The model wants to touch something outside what it was already granted, and is stopped there waiting for you",
"模型提问时": "When the model asks you something",
"模型停下来问你一个问题,没有答案它就不会往下走": "The model stopped to ask a question and will not go on without an answer",
+ "反馈有新回复时": "When feedback gets a reply",
+ "你提交的反馈收到回复、或需要你补充信息时提醒一次,不带回复内容": "One alert when a report you sent gets a reply or needs more from you, without its text",
"引用回复": "quoting a reply",
"引用第 {n} 轮回复": "quoting turn {n}",
"本轮已发送 / 正在接收(估算)的 token": "tokens sent this turn / arriving now (estimated)",
@@ -719,7 +754,10 @@ export const EN: Record = {
"计划已移入右栏": "The plan is in the right rail",
"收工检查": "Wrap-up check",
"查看其后续使用的简报": "See the digest it carries forward",
+ "操作已完成,但会话刷新失败": "The operation completed, but the conversation could not refresh",
+ "重试刷新会话": "Retry session refresh",
"正在还原…": "Restoring…",
+ "会话已切换,请重新选择还原位置": "The conversation changed. Choose a restore point again.",
"撤销这次还原": "Undo this restore",
"仍还原其余部分": "Restore the rest anyway",
"还原失败": "Could not restore",
@@ -984,8 +1022,10 @@ export const EN: Record = {
"上一处": "Previous match",
"下一处": "Next match",
"关闭查找": "Close find",
- "回合结束时给出回执": "End a turn with a receipt",
- "列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
+ "交付验收卡片": "Delivery receipt card",
+ "每一轮结束后,主机会在会话末尾列出这一轮改了什么、验证了什么、哪些没有验证。记录始终保存,这里只决定是否显示。": "At the end of each turn the host lists what the turn changed, what it verified and what it did not. The record is always kept; this only decides whether it is shown.",
+ "显示交付验收卡片": "Show the delivery receipt card",
+ "默认不显示;打开后,当前会话和已有会话里的卡片都会出现。终端界面读同一项设置,下次启动时生效": "Off by default. Turning it on shows the cards in the current session and in earlier ones. The terminal reads the same setting and picks it up the next time it starts",
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
"改写这条消息": "Rewrite this message",
@@ -1021,6 +1061,9 @@ export const EN: Record = {
"确定": "OK",
"重试": "Retry",
"设置": "Settings",
+ "还没有输出,命令仍在运行": "No output yet; the command is still running",
+ "已运行 {time}": "running for {time}",
+ "这条命令自带的超时上限 {n} 秒": "this command's own timeout is {n}s",
"专注": "Focus",
"退出专注": "Leave focus",
"专注:收起两侧栏": "Focus: put the side columns away",
@@ -1070,6 +1113,8 @@ export const EN: Record = {
"另一个会话正在写这个工作区,等它写完会自动继续":
"Another session is writing to this workspace; this one continues on its own when it is safe",
"工作区空出来了,这个会话已经继续": "The workspace is free again; this session has continued",
+ "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受":
+ "An MCP server has tools whose definitions are new or differ from what you approved; they are held out of the model's tools until you accept them (details list them)",
"还没轮到这个会话写,这次等待就结束了": "The wait ended before this session's turn to write came",
// ── 宿主通知卡片自己的头部(NoticeCard)───────────────────────
@@ -1101,7 +1146,9 @@ export const EN: Record = {
"置顶": "Pinned",
"归档": "Archived",
"归档会话": "Archive session",
+ "归档会话:{title}": "Archive session: {title}",
"取消归档": "Unarchive",
+ "取消归档:{title}": "Unarchive: {title}",
"操作执行前由你确认": "Ask before an operation runs",
"只查看内容,不允许写入": "View content without allowing writes",
"当前内核暂不支持会话级只读模式": "The current runtime does not support session-level read-only mode yet",
@@ -1117,6 +1164,10 @@ export const EN: Record = {
"仅显示当前模型实际支持的档位。": "Only levels actually supported by this model are shown.",
"按模型生效": "Per model",
"查看上下文与压缩": "View context and compaction",
+ "立即压缩": "Compact now",
+ "确认压缩": "Confirm compaction",
+ "压缩当前上下文?": "Compact the current context?",
+ "模型将依据摘要继续工作;完整对话记录会保留。": "The model will continue from a summary. The full conversation will be preserved.",
"查看本轮费用": "View turn cost",
"查看钱包余额": "View wallet balance",
"余额不足": "Balance is low",
@@ -1198,6 +1249,22 @@ export const EN: Record = {
"分离状态": "detached",
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
"{n} 个变更": "{n} changes",
+ "1 个文件已更改": "1 file changed",
+ "{n} 个文件已更改": "{n} files changed",
+ "{label},新增 {up} 行,删除 {down} 行,点击查看文件改动": "{label}, {up} lines added, {down} removed. Open file changes",
+ "{label},点击查看文件改动": "{label}. Open file changes",
+ "查看文件改动": "View file changes",
+ "文件改动": "File changes",
+ "在 Git 工作区里,输入框上方显示当前有多少文件已更改,点击查看。关闭后,数量回到分支标签旁。": "In a Git workspace, show how many files have changed above the input box; click to view them. When off, the count stays beside the branch chip.",
+ "在输入框上方显示文件改动": "Show file changes above the input box",
+ "折叠中间过程": "Fold the work in between",
+ "一轮结束后,把工具调用、思考和中间的文字收成一行,只留最后一句话;点那一行可以展开。运行中的一轮始终展开。": "When a turn ends, tool calls, thinking and the sentences in between are gathered into one line and only the last sentence stays out; click the line to open it. The turn that is running stays open.",
+ "折叠一轮里的中间过程": "Fold the work inside a turn",
+ "默认关闭;出错的步骤会在那一行上标出来": "Off by default; a step that failed is marked on that line",
+ "中间过程": "Work in between",
+ "思考与中间文字": "Thinking and notes",
+ "文字 {count}": "Notes: {count}",
+ "默认开启;数量来自 Git 工作区,与本轮对话是否改过文件无关": "On by default. The count comes from the Git work tree, whether or not this conversation edited the files",
"当前工作区 · {n} 个本地变更": "Current workspace · {n} local changes",
"复制回复": "Copy response",
"快速": "Fast",
@@ -1212,6 +1279,8 @@ export const EN: Record = {
"正文行宽": "Body line width",
"适宜阅读": "Reading",
"铺满窗口": "Full width",
+ "连输入框一起铺满": "Full width, composer too",
+ "正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
@@ -1235,6 +1304,8 @@ export const EN: Record = {
"任务清单": "Task list",
"引用到输入框": "Quote into the composer",
"重新生成": "Regenerate",
+ "分支为新会话": "Branch into a new conversation",
+ "分支": "branch",
"当前回复会留在运行历史里": "The current reply stays in the run history",
"按当前配置重试": "Retry as configured",
"先调整模型与强度": "Adjust the model and effort first",
@@ -1312,6 +1383,9 @@ export const EN: Record = {
"The staged diff is long; the draft reads only its first part",
"这个工作区不是 git 仓库,无法提交":
"This workspace is not a git repository, so there is nothing to commit",
+ "回合运行中,等它结束后再分支": "A turn is running; branch after it ends",
+ "这个位置无法分支(重启后恢复的会话,或已被压缩的历史)": "This point cannot be branched (a session restored after a restart, or history that was compacted)",
+ "分支失败,请重试": "Branching failed; try again",
"暂存区是空的,先用 git add 暂存要提交的文件":
"Nothing is staged; stage the files to commit with git add first",
"暂存区在你确认之后又变了,请重新起草":
@@ -1378,10 +1452,13 @@ export const EN: Record = {
"后台任务已结束:{name}": "Background job finished: {name}",
"后台任务已终止:{name}": "Background job killed: {name}",
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
+ "被信号 {signal} 终止": "Ended by signal {signal}",
+ "退出码 {code}": "Exit code {code}",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
+ "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个": "The question wait time in the config file is not valid (it must be a whole number, 0 or 10 to 86400), so questions wait for you as long as it takes; set it again in Settings.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
"有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)": "{n} permission rule(s) name no tool, so they match no call and restrict nothing (for example {rule} in {list}). Delete or rewrite them under Settings > Permissions; a shell command is written Bash(command:*).",
diff --git a/desktop/frontend-next/src/i18n/en_composer.ts b/desktop/frontend-next/src/i18n/en_composer.ts
index 2a0677a56b..f1583304b1 100644
--- a/desktop/frontend-next/src/i18n/en_composer.ts
+++ b/desktop/frontend-next/src/i18n/en_composer.ts
@@ -18,6 +18,7 @@ export const EN_COMPOSER: Record = {
"正在停止…": "Stopping…",
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
+ "描述任务…": "Describe a task…",
"任务输入": "Task input",
"命令与技能": "Commands and skills",
"正在加载命令与技能…": "Loading commands and skills…",
@@ -61,7 +62,7 @@ export const EN_COMPOSER: Record = {
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
"图片": "image",
"图片不可用": "Image unavailable",
- "文件": "file",
+ "文件": "Files",
"粘贴的文本": "pasted text",
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
"添加附件": "Add an attachment",
diff --git a/desktop/frontend-next/src/i18n/en_feedback.ts b/desktop/frontend-next/src/i18n/en_feedback.ts
index def72c0e4d..3bc4abeaf7 100644
--- a/desktop/frontend-next/src/i18n/en_feedback.ts
+++ b/desktop/frontend-next/src/i18n/en_feedback.ts
@@ -76,6 +76,9 @@ export const EN_FEEDBACK: Record = {
"入口": "Surface",
"渠道": "Channel",
"只读。不包含密钥、地址或任何文件内容。": "Read-only. It contains no keys, addresses or file contents.",
+ "打开日志文件夹": "Open logs folder",
+ "维护者向你要日志时,打开这个文件夹,把最新的 shell.log 和 host.log 发给我们。发之前可以先自己看一眼。": "When a maintainer asks for logs, open this folder and send us the newest shell.log and host.log. You can read them first.",
+ "无法打开日志文件夹": "The logs folder could not be opened.",
"去 GitHub": "Go to GitHub",
"重试发送": "Retry sending",
"正文不能为空,请写下你遇到的情况。": "The text is empty. Please describe what happened.",
diff --git a/desktop/frontend-next/src/i18n/en_kernel.ts b/desktop/frontend-next/src/i18n/en_kernel.ts
index 53c777838e..4ed1daf0c4 100644
--- a/desktop/frontend-next/src/i18n/en_kernel.ts
+++ b/desktop/frontend-next/src/i18n/en_kernel.ts
@@ -3,11 +3,13 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record = {
+ "请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
"本界面无响应,这次操作可能没有送达内核,重试前请先确认": "This window is unresponsive; the action may not have reached the kernel - check before retrying",
+ "无法关闭这个网页:{error}": "Could not close this page: {error}",
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
@@ -51,6 +53,8 @@ export const EN_KERNEL: Record = {
"That server would not start, and the switch could not be put back - after a restart it will hold what you just set",
"这个服务要启动的内容在你启用后变了,请先查看命令并重新启用":
"What this server launches changed since you enabled it; review the command and enable it again",
+ "这个服务没有被暂挡的工具定义": "This server has no held tool definitions",
+ "你看到的被挡定义已经变了,没有接受;请重新查看": "The held definitions changed since you saw them, so nothing was accepted; review them again",
"开关未能保存:其存储文件无法读取或写入": "The switch was not stored: its file could not be read or written",
// 待送达:条目、队列、这份存档各自会拒
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
@@ -89,6 +93,14 @@ export const EN_KERNEL: Record = {
"未能导出该插件:{detail}": "That plugin could not be exported: {detail}",
"无法解析本次安装请求:{detail}": "That install request could not be read: {detail}",
"安装失败:{detail}": "It could not be installed: {detail}",
+ "无法读取这个来源:{detail}": "That source could not be read: {detail}",
+ "这个来源里没有可安装的内容:{detail}": "There is nothing installable in this source: {detail}",
+ "来源里的清单没有通过校验:{detail}": "The manifest in the source did not validate: {detail}",
+ "来源与所选类型不符:{detail}": "The source does not match the chosen kind: {detail}",
+ "这个插件没有 Reasonix 能运行的能力:{detail}": "This plugin has no capability Reasonix can run: {detail}",
+ "来源要求先登录或授权:{detail}": "The source requires signing in or authorisation first: {detail}",
+ "找不到运行它所需的命令或运行时:{detail}": "The command or runtime it needs was not found: {detail}",
+ "目标已经存在:{detail}": "The target already exists: {detail}",
"这不是有效的社区包名称": "That is not a community package name",
"社区市场中没有这个已审核的包": "The community market has no approved package by that name",
"无法连接社区市场:{detail}": "The community market could not be reached: {detail}",
@@ -116,6 +128,8 @@ export const EN_KERNEL: Record = {
"无法解析安装器的响应:{detail}": "The installer's own answer could not be read: {detail}",
"无法读取该主题:{detail}": "That theme could not be read: {detail}",
"这不是可安装的主题:{detail}": "That is not an installable theme: {detail}",
+ "这不是有效的主题 id": "That is not a valid theme id",
+ "主题未能删除:{detail}": "The theme could not be removed: {detail}",
"主题未能写入磁盘:{detail}": "The theme could not be written to disk: {detail}",
"无法打开主题目录:{detail}": "The themes folder could not be opened: {detail}",
"记录的面板位置已达上限(最多 {limit} 个),请先清除一个": "Too many panel placements are recorded (at most {limit}); clear one first",
@@ -123,6 +137,18 @@ export const EN_KERNEL: Record = {
"本机的 sandbox-exec 不可用,命令将不受限制地运行": "sandbox-exec is unusable on this machine, so commands run unconfined",
"Windows 上尚无操作系统级沙箱,命令将不受限制地运行": "Windows has no OS-level sandbox here yet, so commands run unconfined",
"该平台尚无可用的沙箱后端,命令将不受限制地运行": "This platform has no usable sandbox backend yet, so commands run unconfined",
+ "当前还有任务在运行,结束后才能更改工作区文件夹": "Finish the current work before changing the workspace folders.",
+ "这台服务器没有开启工作区文件夹编辑": "Workspace folder editing is not enabled on this server.",
+ "请填写文件夹路径": "A folder path is required.",
+ "没有可用的当前模型,无法应用文件夹更改": "There is no current model to apply the folder change on.",
+ "需要一个已存在的文件夹的绝对路径": "Use the absolute path of a folder that exists.",
+ "这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
+ "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
+ "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
+ "这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
+ "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
+ "没有这个预设": "No curated preset has this id.",
+ "这个预设会创建多个来源,名称由预设决定": "This preset creates several sources and keeps its own names.",
"本机没有操作系统沙箱,「关进沙箱」一档无法保存": "This machine has no OS sandbox, so the jailed mode cannot be saved",
"该文件夹仍有 {n} 个打开的面板,请先关闭再移除": "This folder still has {n} open panes — close them before removing it",
@@ -170,10 +196,12 @@ export const EN_KERNEL: Record = {
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
"请填写接口地址": "Enter an endpoint address",
"无法识别「{kind}」这种接入方式": "“{kind}” is not a protocol this knows",
+ "这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header": "This source pins a request URL or auth header written for its current protocol, so its protocol cannot be changed here: remove request_url, chat_url and auth_header from the config file first",
"请至少选择一个模型": "Pick at least one model",
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
"显示名称不能包含换行或其他控制字符": "A display name cannot contain line breaks or other control characters",
+ "显示别名必须使用已选择模型的完整 ID": "Display aliases must use the exact IDs of selected models",
"该协议不发送思考参数,启用后不会生效": "This protocol never sends thinking parameters — turning it on does nothing",
"该协议在轮次之间不保留状态,无需选择续接方式": "This protocol keeps no state between turns, so there is nothing to choose",
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
@@ -247,6 +275,8 @@ export const EN_KERNEL: Record = {
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
"That address did not answer in time. Check the network or proxy, or try again shortly",
+ "该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}":
+ "That address redirected to {target} and was refused so the key is not sent elsewhere. If you trust {target}, change the address to {target}",
"检查失败,没有具体原因": "The check failed and gave no reason",
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
"Cannot reach that address. Check the network, or whether the address has a typo",
@@ -350,6 +380,7 @@ export const EN_KERNEL: Record = {
"该问题已有其他答案": "That question already has a different answer",
"状态图标设置未能保存:{detail}": "The status icon settings were not saved: {detail}",
"内置浏览器的消息格式不正确,本次回传被丢弃": "The built-in browser sent malformed messages, and they were dropped",
+ "这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
@@ -378,8 +409,16 @@ export const EN_KERNEL: Record = {
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
+ "运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
+ "文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
+ "折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
+ "提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
+ "等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
+ "自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
+ "天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
+ "交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
diff --git a/desktop/frontend-next/src/i18n/en_settings.ts b/desktop/frontend-next/src/i18n/en_settings.ts
index d34bc187e6..37a7d4821f 100644
--- a/desktop/frontend-next/src/i18n/en_settings.ts
+++ b/desktop/frontend-next/src/i18n/en_settings.ts
@@ -2,6 +2,13 @@
// alone — one catalogue is still the model, and this file is read as its
// continuation: same keying by Chinese source text, same grouping by screen.
export const EN_SETTINGS: Record = {
+ "{name} 的显示别名": "Display alias for {name}",
+ "显示别名(留空使用模型 ID)": "Display alias (empty uses model ID)",
+ "仅用于显示,不改变请求中的模型 ID": "Display only; the model ID sent in requests is unchanged",
+ "浏览器标签": "Browser tabs",
+ "关闭其他浏览器标签": "Close other browser tabs",
+ "关闭全部浏览器标签": "Close all browser tabs",
+ "无法关闭 {name}:{why}": "Could not close {name}: {why}",
"步骤 1 · 连接服务": "Step 1 · Connect service",
"先连接服务,再选择这个来源要启用的模型。": "Connect the service, then choose which models to enable from it.",
"来源名称": "Source name",
@@ -82,6 +89,13 @@ export const EN_SETTINGS: Record = {
"其他分区与当前会话不受影响;关闭设置后重新打开可重试。":
"Other sections and your session are unaffected; close Settings and reopen to try again.",
"操作未完成": "That did not go through",
+ "{name} 的删除没有完整完成": "Removing {name} did not fully complete",
+ "已删除 {name}": "Removed {name}",
+ "已改用 {to}:{roles}": "Now using {to} for: {roles}",
+ "已清空,对应功能已关闭:{roles}": "Cleared, so these are off: {roles}",
+ "恢复审查": "Recovery review",
+ "小型分类": "Small classifications",
+ "顾问": "Advisor",
// ── 设置:会话 ───────────────────────────────────────────────────
"决定任务完成的判定标准。切换立即生效,不会重建运行时。": "Sets what counts as finished. Switching takes effect immediately and does not rebuild the runtime.",
@@ -257,6 +271,20 @@ export const EN_SETTINGS: Record = {
"移除 {path} 的写入权限": "Stop allowing writes to {path}",
"添加可写目录,例如 /tmp/scratch": "Open one more writable directory, e.g. /tmp/scratch",
"额外可写目录": "Also writable",
+ "此项目的额外文件夹": "Extra folders for this project",
+ "agent 默认只能修改当前项目的文件夹。在这里添加的文件夹也允许写入,只对本项目生效,记在你的用户目录里,不会写进仓库;上面的「额外可写目录」则对所有项目生效。":
+ "The agent can only modify the current project's folder by default. A folder added here is writable too. It applies to this project only and is kept in your user directory, never in the repository; \"Also writable\" above applies to every project.",
+ "「完全放行」只决定是否询问,不会扩大可写范围;要让 agent 修改另一个文件夹,请在这里添加。":
+ "\"Allow everything\" only decides whether to ask; it does not widen where writes can land. To let the agent change another folder, add it here.",
+ "项目文件夹": "Project folders",
+ "添加后,agent 将能修改并删除该文件夹里的任何文件。": "Once added, the agent can change and delete any file in that folder.",
+ "无法读取这个项目的额外文件夹": "Could not read this project's extra folders.",
+ "移除后,已经打开的窗格要等重建后才会收窄可写范围。": "After a removal, panes that are already open narrow their writable area only when they are rebuilt.",
+ "当前项目": "This project",
+ "移除文件夹 {path}": "Remove folder {path}",
+ "要添加的文件夹路径": "Folder path to add",
+ "绝对路径,例如 /Users/you/code/shared-lib": "Absolute path, for example /Users/you/code/shared-lib",
+ "还有 {n} 个窗格正在运行,它们会在下次重建时采用这个改动。": "{n} panes are still running and will take this change at their next rebuild.",
"实际可写": "Actually writable",
"命令执行方式": "How commands run",
"启用沙箱后,命令无法写入清单之外的位置。上方的可写目录清单由操作系统实施,不依赖 agent 自觉遵守。":
@@ -574,6 +602,10 @@ export const EN_SETTINGS: Record = {
"{err} —— 本地功能不受影响,请稍后重试。": "{err} — nothing local is affected; try again later.",
"固定版本为 {pinned},当前运行的是 {current}": "Pinned to {pinned}; what is running is {current}",
"有新版本 {v}": "{v} is out",
+ "当前版本 {v} 已被撤回": "{v} was withdrawn",
+ "撤回原因:{reason}。": "Reason: {reason}. ",
+ "请更新到 {v},可在下方对应行安装。": "Update to {v} from its row below.",
+ "请在下方选择一个版本。": "Pick a version below.",
"切换到 {v} 失败": "Switching to {v} failed",
"已固定在 {v},不再提示新版本": "Pinned to {v}; newer versions are not offered",
"回退时会固定在所选版本,免得新版本提示把你带回刚离开的版本。": "A rollback pins the version you chose, so an update prompt does not take you back to the one you left.",
@@ -737,8 +769,24 @@ export const EN_SETTINGS: Record = {
"模型可承载的输入、工具结果与输出总量。": "The total input, tool results and output the model can hold.",
"单轮生成上限;留空使用内核的模型默认值。": "The per-turn generation limit; leave blank to use the runtime's model default.",
"高级连接选项": "Advanced connection options",
+ "从常用服务开始": "Start from a common service",
+ "留空就用预设自带的名称;重名时内核会自动编号。": "Leave empty to use the preset's own name; the kernel numbers a duplicate.",
+ "模型目录、上下文窗口和思考设置由内核按这个服务的预设一并创建。": "The kernel creates the model catalogue, context window and thinking settings for this service from its preset.",
+ "没有匹配的服务": "No matching service",
+ "预设自带的名称": "The preset's own name",
+ "搜索常用服务": "Search services",
+ "内核没有提供常用服务预设,可以在下面手动填写。": "The kernel offers no service presets; fill the form in below.",
+ "常用服务列表没能读到:{detail}": "The list of common services could not be read: {detail}",
"思考控制、代理与中转站自定义参数": "Thinking controls, proxy and gateway parameters",
"可选": "Optional",
+ "必填": "Required",
+ "日志与异常退出记录": "Logs and abnormal-exit records",
+ "出现问题或闪退后,反馈时附上这个目录里的文件会更容易查清原因;这里只显示位置,不会上传任何内容。": "After a problem or a crash, attaching the files in this folder to a report makes the cause easier to find. This only shows the location; nothing is uploaded.",
+ "还有更早的记录没有列出,请在上面的文件夹里查看。": "Older records are not listed; look in the folder above.",
+ "复制日志目录": "Copy the log folder",
+ "没有留下异常退出的记录。": "No abnormal-exit record has been left.",
+ "内核异常退出时留下的记录,位于上面目录里的 crash 文件夹:": "Records left when the kernel exited abnormally, in the crash folder inside the directory above:",
+ "还有 {n} 项": "{n} more",
"发送思考控制": "Send thinking controls",
"允许当前协议发送思考控制字段;模型是否真的思考仍由模型决定。": "Allow this protocol to send its thinking-control fields. The model still decides whether it actually reasons.",
"当前协议没有思考控制字段,切换模型时仍可在推理强度菜单查看支持情况。": "This protocol has no thinking-control field. Model support is still shown in the reasoning-effort menu.",
@@ -950,10 +998,30 @@ export const EN_SETTINGS: Record = {
"输入 token 上限": "Input token backstop",
"与模型无关的后备:自上次有进展以来的累计输入达到上下文窗口的这个倍数时同样提示。默认 {n} 倍。": "A model-independent backstop: the same notice once the input spent since the last progress reaches this many context windows. Default {n}×.",
"倍窗口": "× window",
+ "主题包过大,请控制在 {limit} MB 以内": "The theme pack is too large; keep it within {limit} MB.",
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
+ "提问等待超时": "Question wait time",
+ "长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
+ "提问超时后自动跳过": "Skip a question nobody answers",
+ "默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
+ "等待时长": "Wait time",
+ "配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
+ "从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
+ "请输入 {min} 到 {max} 之间的整数。": "Enter a whole number from {min} to {max}.",
+ "无法读取提问等待设置。": "The question wait setting could not be read.",
"无法读取无进展设置。": "Could not read the progress settings.",
+ "自动归档不活跃的对话": "Auto-archive inactive conversations",
+ "归档只是把对话移出常用列表,内容不动,随时可在“已归档”里恢复。置顶的、正在运行的、等待你回答或批准的对话不会被归档。只处理已打开会话窗格的文件夹;导入的旧会话按原有的最后活动时间计算,开启后可能一次归档较多。": "Archiving only moves a conversation out of the everyday list; nothing is deleted and you can restore it from Archived at any time. Pinned conversations, running ones and ones waiting for your answer or approval are never archived. Only folders with an open session pane are swept, and imported older conversations count from their original last activity, so enabling this can archive many at once.",
+ "自动归档长时间未活动的对话": "Archive conversations left idle",
+ "默认关闭。开启后,超过下面天数没有活动的对话会在后台被归档。": "Off by default. When on, a conversation with no activity for longer than the number of days below is archived in the background.",
+ "闲置天数": "Idle days",
+ "距上次活动超过这个天数才会归档。默认 {n} 天。": "Archived only once it has been idle longer than this. Default {n} days.",
+ "天": "days",
+ "无法读取自动归档设置。": "Could not read the auto-archive settings.",
+ "已归档": "Archived",
+ "已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
"图标栏": "Icon rail",
- "会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens usage, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
+ "会话侧栏左边的一列图标,直达会话、用量、文件、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens conversation, usage, files, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
"始终显示": "Always",
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
"不显示": "Never",
diff --git a/desktop/frontend-next/src/i18n/en_sponsor.ts b/desktop/frontend-next/src/i18n/en_sponsor.ts
new file mode 100644
index 0000000000..2f26ca2405
--- /dev/null
+++ b/desktop/frontend-next/src/i18n/en_sponsor.ts
@@ -0,0 +1,10 @@
+export const EN_SPONSOR: Record = {
+ "赞助": "Support the project",
+ "如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。这是一杯咖啡,不是合同:捐助不会换来功能优先级,也不会改变问题的处理顺序。": "If Reasonix has saved you time or tokens, you can buy a coffee. A coffee, not a contract: donations don't buy feature priority or change how issues get triaged.",
+ "显示收款码": "Show payment QR codes",
+ "收起收款码": "Hide payment QR codes",
+ "微信支付": "WeChat Pay",
+ "支付宝": "Alipay",
+ "微信支付收款码": "WeChat Pay QR code",
+ "支付宝收款码": "Alipay QR code",
+};
diff --git a/desktop/frontend-next/src/i18n/en_trajectory.ts b/desktop/frontend-next/src/i18n/en_trajectory.ts
index dc0e88dc73..d7a0f78d2c 100644
--- a/desktop/frontend-next/src/i18n/en_trajectory.ts
+++ b/desktop/frontend-next/src/i18n/en_trajectory.ts
@@ -24,6 +24,10 @@ export const EN_TRAJECTORY: Record = {
"系统": "System",
"信号轨": "Signal rail",
"与下方共用时间轴": "Shares the timeline below",
+ "时间轴缩放": "Timeline zoom",
+ "重置缩放": "Reset zoom",
+ "没有时长、也不是工具调用的运行记录,位置就是时间,无法拖动;缩放后可横向滚动,重叠信号分行显示。点一个看它的记录。":
+ "Records with no duration that are not tool calls. Their position is their time, so they cannot be dragged. Zoom and scroll horizontally; overlapping signals use separate lanes. Select one to inspect its record.",
"活动回合": "Activity rounds",
"点击一行查看记录": "Select a row to inspect it",
"模型回合": "Model round",
diff --git a/desktop/frontend-next/src/i18n/en_window.ts b/desktop/frontend-next/src/i18n/en_window.ts
index 9330a527e9..337ca7c171 100644
--- a/desktop/frontend-next/src/i18n/en_window.ts
+++ b/desktop/frontend-next/src/i18n/en_window.ts
@@ -3,6 +3,20 @@
// en_settings and en_kernel were — one screen's worth of wording, read together.
export const EN_WINDOW: Record = {
+ "选择本机会话": "Select local sessions",
+ "选择会话:{name}": "Select session: {name}",
+ "已选 {n} 个会话": "{n} sessions selected",
+ "完成选择": "Done selecting",
+ "全选当前显示的会话": "Select all displayed sessions",
+ "取消当前显示会话的选择": "Deselect displayed sessions",
+ "取消全选": "Deselect all",
+ "全选可见": "Select visible",
+ "归档所选会话": "Archive selected sessions",
+ "取消所选会话归档": "Unarchive selected sessions",
+ "删除所选会话": "Delete selected sessions",
+ "所选会话正在运行,停止后才能操作": "Selected sessions are running. Stop them before continuing.",
+ "正在处理所选会话…": "Processing selected sessions…",
+ "删除 {n} 个会话?": "Delete {n} sessions?",
"运行指标": "Run metrics",
"时间线": "Timeline",
"定位": "Locate",
@@ -25,6 +39,7 @@ export const EN_WINDOW: Record = {
"计划已生成,如何执行由你决定。": "The plan is written. What happens next is your call.",
"开始执行": "Start execution",
"修改计划": "Revise the plan",
+ "目标": "Goal",
"暂不执行": "Not now",
"「修改计划」将保持在计划模式中,请在下方说明需要修改之处。": "“Revise the plan” stays in plan mode — say what to change below and the planner rewrites it.",
"已开始执行。": "Executing.",
@@ -34,6 +49,9 @@ export const EN_WINDOW: Record = {
"继续规划。": "Still planning.",
"在下方说明需要修改的内容,规划者将据此重写该计划。": "Say what to change below and the planner rewrites the plan.",
"主导航": "Main navigation",
+ "松开以添加项目文件夹": "Drop to add a project folder",
+ "请一次拖入一个文件夹": "Drop one folder at a time",
+ "无法取得本机路径,请使用添加文件夹": "The local path is unavailable. Use Add folder instead.",
"浏览器": "Browser",
"空白页": "Blank page",
"智能体正在查看的页面": "Page the agent is viewing",
@@ -95,7 +113,7 @@ export const EN_WINDOW: Record = {
"Reasonix 内置 Browser": "Reasonix built-in Browser",
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
"正在打开网页…": "Opening page…",
- "关闭内置 Browser": "Close the built-in Browser",
+ "收起工作台": "Collapse the workbench",
"打开内置 Browser": "Open the built-in Browser",
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
diff --git a/desktop/frontend-next/src/i18n/format.ts b/desktop/frontend-next/src/i18n/format.ts
index 2e1a8497ed..c8f78c08fc 100644
--- a/desktop/frontend-next/src/i18n/format.ts
+++ b/desktop/frontend-next/src/i18n/format.ts
@@ -43,6 +43,15 @@ export function tokens(n: number): string {
return `${decimals(n / 1_000_000, 1)}M`;
}
+/** elapsed is a run time said compactly: seconds under a minute, then minutes
+ * and hours, a zero unit left out. It matches the terminal's wording. */
+export function elapsed(ms: number): string {
+ const total = Math.round(ms / 1000);
+ if (total < 60) return `${total}s`;
+ const parts = [[Math.floor(total / 3600), "h"], [Math.floor((total % 3600) / 60), "m"], [total % 60, "s"]] as const;
+ return parts.filter(([n]) => n > 0).map(([n, u]) => `${n}${u}`).join(" ");
+}
+
/** seconds spells a millisecond duration. One place by default — the callers
* that used to pick their own each picked a different one. */
export const seconds = (ms: number, digits = 1) => `${decimals(ms / 1000, digits)}s`;
diff --git a/desktop/frontend-next/src/i18n/job_notice.ts b/desktop/frontend-next/src/i18n/job_notice.ts
index 49612b2eb5..059b3dc4ec 100644
--- a/desktop/frontend-next/src/i18n/job_notice.ts
+++ b/desktop/frontend-next/src/i18n/job_notice.ts
@@ -1,3 +1,5 @@
+import { t } from ".";
+
export const JOB_FINISHED = "job_finished";
export const JOB_KILLED = "job_killed";
export const JOB_FAILED = "job_failed";
@@ -16,10 +18,15 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
}
}
+// How a failed process ended, worded from the typed fields the producer wrote:
+// a signal first, since an exit code is then only the shell's echo of it. The
+// job function's own error text stands in when the producer did not type it.
export function jobErrorText(detail: string | undefined): string | undefined {
if (!detail) return undefined;
try {
- const p = JSON.parse(detail) as { error?: unknown };
+ const p = JSON.parse(detail) as { error?: unknown; exitCode?: unknown; signal?: unknown };
+ if (typeof p.signal === "number" && p.signal > 0) return t("被信号 {signal} 终止", { signal: p.signal });
+ if (typeof p.exitCode === "number") return t("退出码 {code}", { code: p.exitCode });
return typeof p.error === "string" && p.error !== "" ? p.error : undefined;
} catch {
return undefined;
diff --git a/desktop/frontend-next/src/i18n/kernel.test.ts b/desktop/frontend-next/src/i18n/kernel.test.ts
index 7ac2a494f6..2e99874f97 100644
--- a/desktop/frontend-next/src/i18n/kernel.test.ts
+++ b/desktop/frontend-next/src/i18n/kernel.test.ts
@@ -24,6 +24,12 @@ const coded = (message: string, code: string, params?: Record {
+ it("asks for a currency retry when its runtime owner changed", () => {
+ const refusal = coded("fixture fallback", "display_currency.runtime_changed");
+ expect(reason(refusal)).toBe("运行时已更换,费用显示币种未保存,请重试");
+ localStorage.setItem(STORAGE, "en"); boot();
+ expect(reason(refusal)).toBe("The runtime changed; the display currency was not saved. Try again.");
+ });
it("renders shell refusal causes in Chinese and English", () => {
for (const code of ["shell.destructive_target", "shell.analysis_unknown", "shell.delete_sequence", "shell.delete_nonliteral", "shell.delete_option", "shell.syntax_error", "shell.parser_unavailable", "shell.parser_timeout", "shell.command_line_too_long"]) {
localStorage.setItem(STORAGE, "zh"); boot();
diff --git a/desktop/frontend-next/src/i18n/kernel.ts b/desktop/frontend-next/src/i18n/kernel.ts
index a22eb8535b..85e3fbc511 100644
--- a/desktop/frontend-next/src/i18n/kernel.ts
+++ b/desktop/frontend-next/src/i18n/kernel.ts
@@ -60,11 +60,13 @@ const SAID: Record = {
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
"provider.endpoint_required": "请填写接口地址",
+ "provider.kind_pinned_endpoint": "这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header",
"provider.kind_unsupported": "无法识别「{kind}」这种接入方式",
"provider.no_models_picked": "请至少选择一个模型",
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
"provider.display_name_too_long": "显示名称最多 {max} 个字符",
"provider.display_name_invalid": "显示名称不能包含换行或其他控制字符",
+ "provider.model_alias_unlisted": "显示别名必须使用已选择模型的完整 ID",
// ── 来源:这个协议做不到 ─────────────────────────────────────────
"provider.no_thinking_param": "该协议不发送思考参数,启用后不会生效",
@@ -164,6 +166,7 @@ const SAID: Record = {
// ── 来源:连接与授权 ─────────────────────────────────────────────
"provider.editing_disabled": "这台服务器不允许修改模型来源",
+ "browser.close_failed": "无法关闭这个网页:{error}",
"browser.open_failed": "打不开这个网页:{error}",
"browser.network_path": "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问",
"browser.engine_missing": "没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到",
@@ -226,6 +229,7 @@ const SAID: Record = {
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
+ "provider.probe.redirect_refused": "该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}",
"provider.probe.failed": "检查失败,没有具体原因",
"provider.probe.decision_path_not_found": "地址可以连通,但没有 System One 决策接口。请填写服务的基地址,不要带 /v1/systemone,Reasonix 会自己在后面追加",
"provider.probe.decision_not_compatible": "该地址有响应,但返回的不是 System One 的决策结果。请确认这是决策协议服务的基地址",
@@ -247,6 +251,10 @@ const SAID: Record = {
"prompt_refine.failed": "优化失败,请重试",
"prompt_refine.bad_request": "优化请求格式不正确",
+ "conversation_branch.turn_running": "回合运行中,等它结束后再分支",
+ "conversation_branch.unavailable": "这个位置无法分支(重启后恢复的会话,或已被压缩的历史)",
+ "conversation_branch.failed": "分支失败,请重试",
+
"commit.no_repository": "这个工作区不是 git 仓库,无法提交",
"commit.nothing_staged": "暂存区是空的,先用 git add 暂存要提交的文件",
"commit.staged_changed": "暂存区在你确认之后又变了,请重新起草",
@@ -283,6 +291,7 @@ const SAID: Record = {
"session.bind_failed": "接管该会话失败,请重新打开窗口",
"session.has_open_pane": "该会话仍有打开的面板,请先关闭",
"session.outside_workspace": "该路径不属于任何已知的工作区",
+ "settings.local_runtime_required": "请先打开一个本机会话,再读取本机设置",
"hub.no_runtime_open": "尚未打开任何会话",
// ── 远程 ─────────────────────────────────────────────────────────
@@ -322,6 +331,8 @@ const SAID: Record = {
"mcp.unavailable": "该服务器未能启动,开关已恢复原状",
"mcp.switch_not_undone": "该服务器未能启动,且开关未能恢复——重启后将保持刚才设置的状态",
"mcp.approval_owed": "这个服务要启动的内容在你启用后变了,请先查看命令并重新启用",
+ "mcp.nothing_held": "这个服务没有被暂挡的工具定义",
+ "mcp.digest_mismatch": "你看到的被挡定义已经变了,没有接受;请重新查看",
"activation.unavailable": "开关未能保存:其存储文件无法读取或写入",
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
@@ -353,8 +364,16 @@ const SAID: Record = {
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
+ "display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
+ "changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
+ "fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
+ "ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
+ "ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
+ "receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
+ "auto_archive.save_failed": "自动归档设置未能保存:{detail}",
+ "auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
"workspace.untrustable": "主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹",
"workspace.trust_save_failed": "未能记下对此文件夹的信任决定:{detail}",
"progress_watch.out_of_range": "该数值超出允许范围,未做任何修改:{detail}",
@@ -373,6 +392,14 @@ const SAID: Record = {
"plugin.export_failed": "未能导出该插件:{detail}",
"install.request_unreadable": "无法解析本次安装请求:{detail}",
"install.failed": "安装失败:{detail}",
+ "install.source_unreadable": "无法读取这个来源:{detail}",
+ "install.manifest_missing": "这个来源里没有可安装的内容:{detail}",
+ "install.manifest_invalid": "来源里的清单没有通过校验:{detail}",
+ "install.unsupported_kind": "来源与所选类型不符:{detail}",
+ "install.no_compatible_capabilities": "这个插件没有 Reasonix 能运行的能力:{detail}",
+ "install.auth_required": "来源要求先登录或授权:{detail}",
+ "install.binary_missing": "找不到运行它所需的命令或运行时:{detail}",
+ "install.already_exists": "目标已经存在:{detail}",
"install.bad_answer": "无法解析安装器的响应:{detail}",
"market.bad_slug": "这不是有效的社区包名称",
"market.not_found": "社区市场中没有这个已审核的包",
@@ -401,12 +428,26 @@ const SAID: Record = {
"theme.unreadable": "无法读取该主题:{detail}",
"theme.not_a_pack": "这不是可安装的主题:{detail}",
"theme.install_failed": "主题未能写入磁盘:{detail}",
+ "theme.bad_id": "这不是有效的主题 id",
+ "theme.remove_failed": "主题未能删除:{detail}",
"theme.folder_failed": "无法打开主题目录:{detail}",
"surface.too_many_slots": "记录的面板位置已达上限(最多 {limit} 个),请先清除一个",
"sandbox.no_bubblewrap": "本机未安装 bubblewrap(bwrap),命令将不受限制地运行",
"sandbox.no_sandbox_exec": "本机的 sandbox-exec 不可用,命令将不受限制地运行",
"sandbox.unsupported_on_windows": "Windows 上尚无操作系统级沙箱,命令将不受限制地运行",
"sandbox.unsupported_platform": "该平台尚无可用的沙箱后端,命令将不受限制地运行",
+ "workspace_roots.busy": "当前还有任务在运行,结束后才能更改工作区文件夹",
+ "workspace_roots.editing_disabled": "这台服务器没有开启工作区文件夹编辑",
+ "workspace_roots.path_required": "请填写文件夹路径",
+ "workspace_roots.rebuild_unavailable": "没有可用的当前模型,无法应用文件夹更改",
+ "workspace_roots.not_directory": "需要一个已存在的文件夹的绝对路径",
+ "workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
+ "workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
+ "workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
+ "workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
+ "workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
+ "provider.preset_unknown": "没有这个预设",
+ "provider.preset_named_entries": "这个预设会创建多个来源,名称由预设决定",
"sandbox.unavailable": "本机没有操作系统沙箱,「关进沙箱」一档无法保存",
"remote.install_disabled": "{host} 上没有 reasonix,且本机已设置为不自动安装。请将安装方式改回「自动」,或自行在该机器上安装",
"remote.npm_unavailable": "{host} 上无法运行 npm —— 通常是该机器未安装 Node.js。请安装 Node.js,或将安装方式改为「上传」",
@@ -448,6 +489,7 @@ const SAID: Record = {
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
+ "studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
"studio.pin_rejected": "版本固定未能保存:{detail}",
"studio.notes_bad_version": "这不是一个已发布的版本号",
"studio.notes_absent": "这个版本没有发布更新内容",
diff --git a/desktop/frontend-next/src/i18n/notice_wording.test.ts b/desktop/frontend-next/src/i18n/notice_wording.test.ts
new file mode 100644
index 0000000000..6a83a866ef
--- /dev/null
+++ b/desktop/frontend-next/src/i18n/notice_wording.test.ts
@@ -0,0 +1,67 @@
+import { describe, expect, it } from "vitest";
+import { NOTICE_TEXT } from "./notices";
+import { NOTICE_VAR_CODES, leftoverDetail, noticeWording } from "./notice_wording";
+
+const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
+
+const PAYLOAD: Record = {
+ extension_skipped: JSON.stringify({ extension: "e", point: "tool.before" }),
+ inbox_recovered: JSON.stringify({ count: 2 }),
+ permission_rules_dormant: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
+ job_finished: JSON.stringify({ id: "j", label: "build" }),
+ job_killed: JSON.stringify({ id: "j", label: "build" }),
+ job_failed: JSON.stringify({ id: "j", label: "build" }),
+ display_currency: "USD",
+ compact_declined: undefined,
+ compact_failed: "summary_failed",
+ compact_held: "summary_failed",
+};
+
+describe("notice wording", () => {
+ const withSlots = Object.entries(NOTICE_TEXT).filter(([, s]) => placeholders(s).length > 0);
+
+ it.each(withSlots)("%s registers a reader for every placeholder it has", (code, sentence) => {
+ expect(NOTICE_VAR_CODES, `${code} has placeholders but no variable reader in notice_wording.ts`).toContain(code);
+ expect(code in PAYLOAD, `add a sample payload for ${code}`).toBe(true);
+ const w = noticeWording(code, PAYLOAD[code]);
+ expect(w).toBeDefined();
+ for (const key of placeholders(sentence)) expect(w?.vars).toHaveProperty(key);
+ });
+
+ it.each(NOTICE_VAR_CODES)("%s yields nothing, never a half-filled sentence, for an unreadable detail", (code) => {
+ if (code === "display_currency" || code === "compact_declined") return;
+ expect(noticeWording(code, "not json")).toBeUndefined();
+ });
+
+ it("words a placeholder-free code without variables", () => {
+ expect(noticeWording("verification_stalled", "anything")?.vars).toBeUndefined();
+ });
+
+ it("has no wording for an unknown code", () => {
+ expect(noticeWording("some_future_code", "x")).toBeUndefined();
+ expect(noticeWording(undefined, "x")).toBeUndefined();
+ });
+
+ it.each([
+ ["extension_skipped", '{"extension":"","point":"p"}'],
+ ["extension_skipped", '{"extension":3,"point":"p"}'],
+ ["inbox_recovered", '{"count":"2"}'],
+ ["inbox_recovered", '{"count":0}'],
+ ["inbox_recovered", '{"count":1.5}'],
+ ["permission_rules_dormant", '{"rules":[]}'],
+ ["permission_rules_dormant", '{"rules":[{"list":"ask"}]}'],
+ ["permission_rules_dormant", '{"rules":[{"list":1,"rule":"x"}]}'],
+ ["job_finished", '{"label":"build"}'],
+ ["job_killed", '{"id":""}'],
+ ["job_failed", '{"id":5}'],
+ ["compact_failed", "no_such_reason"],
+ ])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
+ expect(noticeWording(code, detail)).toBeUndefined();
+ });
+
+ it("keeps the diagnostic only where the detail was not read as a payload", () => {
+ expect(leftoverDetail("job_failed", '{"id":"j","error":"exit 2"}', { name: "j" })).toBe("exit 2");
+ expect(leftoverDetail("job_finished", '{"id":"j"}', { name: "j" })).toBeUndefined();
+ expect(leftoverDetail("compact_failed", "future_code", undefined)).toBe("future_code");
+ });
+});
diff --git a/desktop/frontend-next/src/i18n/notice_wording.ts b/desktop/frontend-next/src/i18n/notice_wording.ts
new file mode 100644
index 0000000000..910cdd20f6
--- /dev/null
+++ b/desktop/frontend-next/src/i18n/notice_wording.ts
@@ -0,0 +1,50 @@
+import { t } from "./index";
+import { FOLD_WHY, NO_CODE_WHY } from "./compaction_why";
+import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
+import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
+import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
+import { NOTICE_TEXT } from "./notices";
+import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
+
+export type NoticeVars = Record;
+
+const compactReason = (code: string) => (detail: string | undefined): NoticeVars | undefined => {
+ const why = detail ? FOLD_WHY[detail] : code === "compact_declined" ? NO_CODE_WHY : undefined;
+ return why ? { why: t(why) } : undefined;
+};
+
+// Every code whose sentence has a placeholder reads its values from the detail
+// here; a placeholder code missing from this table fails notice_wording.test.ts.
+const VARS: Record NoticeVars | undefined> = {
+ [EXTENSION_SKIPPED]: extensionSkippedVars,
+ [INBOX_RECOVERED]: inboxRecoveredVars,
+ [PERMISSION_RULES_DORMANT]: permissionRulesDormantVars,
+ [JOB_FINISHED]: jobNoticeVars,
+ [JOB_KILLED]: jobNoticeVars,
+ [JOB_FAILED]: jobNoticeVars,
+ display_currency: (detail) => ({ mode: detail || "auto" }),
+ compact_declined: compactReason("compact_declined"),
+ compact_failed: compactReason("compact_failed"),
+ compact_held: compactReason("compact_held"),
+};
+
+export const NOTICE_VAR_CODES: readonly string[] = Object.keys(VARS);
+
+// The sentence for a kernel code in this window's language, with the values its
+// placeholders need. Undefined when the code has no sentence or its detail does
+// not carry the values, so the caller keeps the kernel's own English.
+export function noticeWording(code: string | undefined, detail: string | undefined): { text: string; vars?: NoticeVars } | undefined {
+ const text = code ? NOTICE_TEXT[code] : undefined;
+ if (!code || !text) return undefined;
+ const read = VARS[code];
+ if (!read) return /\{\w+\}/.test(text) ? undefined : { text };
+ const vars = read(detail);
+ return vars ? { text, vars } : undefined;
+}
+
+// What of the detail is still worth drawing under the sentence: all of it when
+// nothing was read from it, only the job's error text when it was a payload.
+export function leftoverDetail(code: string | undefined, detail: string | undefined, vars: NoticeVars | undefined): string | undefined {
+ if (!vars) return detail;
+ return code === JOB_FAILED ? jobErrorText(detail) : undefined;
+}
diff --git a/desktop/frontend-next/src/i18n/notices.ts b/desktop/frontend-next/src/i18n/notices.ts
index dc8c74c1ec..8b77f01e0a 100644
--- a/desktop/frontend-next/src/i18n/notices.ts
+++ b/desktop/frontend-next/src/i18n/notices.ts
@@ -16,6 +16,7 @@ export const NOTICE_TEXT: Record = {
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
project_mcp_awaiting_approval: "这个项目声明的一个 MCP 服务要你批准后才会运行;详情里是它要启动的命令",
project_mcp_changed: "一个已启用的项目 MCP 服务要启动的内容变了,本次没有运行,需要查看命令后重新启用",
+ mcp_tools_held: "一个 MCP 服务有工具的定义是新增的或与你批准过的不同,已暂不提供给模型;详情里是这些工具,核对服务后再接受",
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
await_user: "等待你的输入",
@@ -29,6 +30,7 @@ export const NOTICE_TEXT: Record = {
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
+ input_refused: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
display_currency: "费用显示币种已设为 {mode}",
job_finished: "后台任务已结束:{name}",
@@ -40,5 +42,6 @@ export const NOTICE_TEXT: Record = {
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
+ ask_timeout_invalid: "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
diff --git a/desktop/frontend-next/src/main.tsx b/desktop/frontend-next/src/main.tsx
index 98aa5dcf4d..2d13db3f55 100644
--- a/desktop/frontend-next/src/main.tsx
+++ b/desktop/frontend-next/src/main.tsx
@@ -5,11 +5,26 @@ import { reason } from "./i18n/kernel";
import { track as trackWidth } from "./ui/viewport";
import "./styles/tokens.css";
import "./styles/app.css";
+import "./styles/dock.css";
import "./styles/studio.css";
+import "./styles/planpeek.css";
+import "./styles/phonecomposer.css";
+import "./styles/tips.css";
+import "./styles/waitnote.css";
+import "./styles/narrow.css";
+import "./styles/workfold.css";
+import "./styles/workbench-launcher.css";
import "./styles/feedback.css";
import "./styles/community.css";
+import "./styles/themes.css";
+import "./styles/sponsor.css";
import "./styles/chart.css";
import "./styles/versions.css";
+import "./styles/changedpill.css";
+import "./styles/sessionnotes.css";
+import "./styles/providerpresets.css";
+import "./styles/sidebardrop.css";
+import "./styles/sessionquick.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
diff --git a/desktop/frontend-next/src/orphan.test.ts b/desktop/frontend-next/src/orphan.test.ts
index 0ed8e95514..e62e7e4a1f 100644
--- a/desktop/frontend-next/src/orphan.test.ts
+++ b/desktop/frontend-next/src/orphan.test.ts
@@ -15,7 +15,7 @@ const SOURCES = import.meta.glob(["./**/*.{ts,tsx}", "../perf/**/*.{ts,tsx}", "!
// Entry points are reached by the bundler, not by an import. Declared, never
// inferred — nothing in a filename says whether something mounts it.
-const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx"]);
+const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx", "src/testsetup.ts"]);
/** One spelling for every file, rooted at the project rather than at whichever
* directory the glob happened to resolve it from. */
diff --git a/desktop/frontend-next/src/port/adjudication.ts b/desktop/frontend-next/src/port/adjudication.ts
index cdc4b88c6e..9b34ea9381 100644
--- a/desktop/frontend-next/src/port/adjudication.ts
+++ b/desktop/frontend-next/src/port/adjudication.ts
@@ -9,7 +9,7 @@
// `state` comes from the kernel because "interrupted" appears in no record: it
// is what an open barrier means once nothing is waiting on it, and only the
// host knows whether anything still is.
-export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "superseded";
+export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "timed_out" | "superseded";
export interface AdjudicationEntry {
barrier_id: string;
diff --git a/desktop/frontend-next/src/port/boundary.ts b/desktop/frontend-next/src/port/boundary.ts
index 04fc331927..4bc91f6460 100644
--- a/desktop/frontend-next/src/port/boundary.ts
+++ b/desktop/frontend-next/src/port/boundary.ts
@@ -11,6 +11,12 @@ export interface PermissionLists {
deny: string[];
}
+export interface DormantPermissionRule {
+ list: string;
+ rule: string;
+ tool: string;
+}
+
// Editable user lists and separate project rules that are in force. shadowedBy
// names a project config that outranks the edited file; effective is the
// merged boundary the controller actually loaded.
@@ -25,7 +31,7 @@ export interface PermissionRules extends PermissionLists {
shadowedBy?: string;
effective?: PermissionLists;
// Saved rules that name no tool, so they match nothing.
- dormant?: { list: string; rule: string; tool: string }[];
+ dormant?: DormantPermissionRule[];
}
// Where an approved write may land, and whether bash runs jailed. The
@@ -55,6 +61,15 @@ export interface SandboxSettings {
shadowedBy?: string;
}
+// The project's primary folder and the extra folders the user granted it write
+// access to. pending counts other panes on the project that were busy and take
+// the change at their next rebuild.
+export interface WorkspaceRoots {
+ primary: string;
+ extra: string[];
+ pending?: number;
+}
+
// What a settings surface shows in place of a save it cannot perform. The
// kernel refuses every write while the file will not parse, so this arrives
// before anything is tried rather than as each panel's own error.
@@ -84,8 +99,13 @@ export interface BrowserToolsSettings {
path: string;
}
-// When a run reads as no longer moving. Only the user file holds it: a
-// project file cannot pause the user's runs.
+export interface AutoArchiveSettings {
+ enabled: boolean;
+ days: number;
+ defaultDays: number;
+ path: string;
+}
+
export type DisplayCurrencyMode = "auto" | "CNY" | "USD";
// Which currency costs are shown in; the user file holds it.
@@ -94,6 +114,41 @@ export interface DisplayCurrencySettings {
path: string;
}
+// Whether the changed-files pill is drawn above the composer; the user file
+// holds it.
+// Whether Studio folds a turn's intermediate work under one line; the user file
+// holds it.
+export interface FoldWorkSettings {
+ fold: boolean;
+ path: string;
+}
+
+export interface ChangedFilesSettings {
+ show: boolean;
+ path: string;
+}
+
+// When a run reads as no longer moving. Only the user file holds it: a
+// project file cannot pause the user's runs.
+// How long an ask question waits before the host skips it; 0 waits. The user
+// file holds it, so the terminal reads the same answer.
+export interface AskTimeoutSettings {
+ seconds: number;
+ // The kernel's code for a value the file holds that cannot be applied.
+ invalid?: string;
+ defaultSeconds: number;
+ minSeconds: number;
+ maxSeconds: number;
+ path: string;
+}
+
+// Whether the end-of-turn delivery receipt is drawn as a card; the user file
+// holds it, so the terminal reads the same answer.
+export interface ReceiptSettings {
+ show: boolean;
+ path: string;
+}
+
export interface ProgressWatchSettings {
pause: boolean;
rounds: number;
diff --git a/desktop/frontend-next/src/port/host.ts b/desktop/frontend-next/src/port/host.ts
index ed48515ec9..6778286ba1 100644
--- a/desktop/frontend-next/src/port/host.ts
+++ b/desktop/frontend-next/src/port/host.ts
@@ -13,6 +13,15 @@ export interface HostInfo {
titleBar: boolean;
}
+/** How this launch draws. `launchedOff` is what the shell applied at start,
+ * `savedOff` the saved choice alone (absent from an older shell), and
+ * `compositing` Electron's own status string ("" when it has none yet). */
+export interface GraphicsInfo {
+ launchedOff: boolean;
+ savedOff?: boolean;
+ compositing: string;
+}
+
export interface HostPort {
/** Whether this page runs inside the desktop shell, as opposed to a browser
* tab, where the browser's own keys (zoom among them) are the reader's. */
@@ -22,6 +31,8 @@ export interface HostPort {
toggleMaximiseWindow(): void;
isWindowMaximised(): Promise;
closeWindow(): void;
+ /** How the shell draws this launch; null where it has no say, as in a tab. */
+ graphics(): Promise;
openExternal(url: string): void | Promise;
/** Where dropped files live. Empty where the shell cannot say — a browser
* tab never learns a path. */
@@ -44,6 +55,11 @@ export interface HostPort {
/** Show a project the sidebar lists, which needs no pane. The hub answers
* where it is and refuses any folder it does not list. */
revealWorkspace(root: string): Promise;
+ /** Whether this shell can open the folder it writes its own logs to. */
+ revealsLogs(): boolean;
+ /** Open that folder in the platform's file manager; null once shown,
+ * otherwise why it was not. */
+ revealLogs(): Promise;
/** Whether this shell draws the agent's browser pages inside the window. */
drawsBrowserViews(): boolean;
/** Draw one of the agent's pages over rect, in on-screen coordinates, and
@@ -131,6 +147,7 @@ interface ElectronBridge {
toggleMaximiseWindow(): Promise;
isWindowMaximised(): Promise;
closeWindow(): Promise;
+ graphics?(): Promise;
openExternal(url: string): Promise;
pathForFile(file: File): string;
saveText(name: string, content: string): Promise;
@@ -138,6 +155,7 @@ interface ElectronBridge {
pickFolder(startIn: string): Promise;
revealPath?(base: string, path: string): Promise;
revealWorkspace?(root: string): Promise;
+ revealLogs?(): Promise;
showBrowserView?(target: string, rect: ViewRect): Promise;
hideBrowserView?(): Promise;
freezeBrowserView?(): Promise;
@@ -182,6 +200,9 @@ class ElectronHost implements HostPort {
closeWindow() {
void this.api.closeWindow();
}
+ graphics() {
+ return this.api.graphics?.().catch(() => null) ?? Promise.resolve(null);
+ }
openExternal(url: string) {
return this.api.openExternal(url);
}
@@ -208,6 +229,12 @@ class ElectronHost implements HostPort {
revealWorkspace(root: string) {
return this.api.revealWorkspace?.(root) ?? Promise.resolve({ error: "this shell cannot show files" });
}
+ revealsLogs() {
+ return typeof this.api.revealLogs === "function";
+ }
+ revealLogs() {
+ return this.api.revealLogs?.() ?? Promise.resolve({ error: "this shell cannot show files" });
+ }
// A shell older than the verbs has no views to draw, and says so by lacking them.
drawsBrowserViews() {
return typeof this.api.showBrowserView === "function";
@@ -256,6 +283,9 @@ class BrowserHost implements HostPort {
return Promise.resolve(false);
}
closeWindow() {}
+ graphics() {
+ return Promise.resolve(null);
+ }
openExternal(url: string) {
window.open(url, "_blank", "noopener,noreferrer");
}
@@ -280,6 +310,12 @@ class BrowserHost implements HostPort {
revealWorkspace() {
return Promise.resolve({ error: "a browser tab cannot show files" });
}
+ revealsLogs() {
+ return false;
+ }
+ revealLogs() {
+ return Promise.resolve({ error: "a browser tab cannot show files" });
+ }
drawsBrowserViews() {
return false;
}
diff --git a/desktop/frontend-next/src/port/host_reveal.test.ts b/desktop/frontend-next/src/port/host_reveal.test.ts
index 08f2485b13..640508078d 100644
--- a/desktop/frontend-next/src/port/host_reveal.test.ts
+++ b/desktop/frontend-next/src/port/host_reveal.test.ts
@@ -12,3 +12,11 @@ it("offers reveal only when the shell can show both a pane's file and a project"
expect((await hostOver({ revealPath: async () => null, revealWorkspace: async () => null })).revealsFiles()).toBe(true);
expect((await hostOver({ revealPath: async () => null })).revealsFiles()).toBe(false);
});
+
+it("offers the logs folder only when the shell can open it, and passes its refusal through", async () => {
+ expect((await hostOver({})).revealsLogs()).toBe(false);
+ const refusing = await hostOver({ revealLogs: async () => ({ error: "no such folder" }) });
+ expect(refusing.revealsLogs()).toBe(true);
+ expect(await refusing.revealLogs()).toEqual({ error: "no such folder" });
+ expect(await (await hostOver({ revealLogs: async () => null })).revealLogs()).toBeNull();
+});
diff --git a/desktop/frontend-next/src/port/hub.ts b/desktop/frontend-next/src/port/hub.ts
index 85dcf54084..1e8d7547f1 100644
--- a/desktop/frontend-next/src/port/hub.ts
+++ b/desktop/frontend-next/src/port/hub.ts
@@ -1,3 +1,4 @@
+import { watchTree } from "./tree_events";
import { HttpError, type AgentPort } from "./port";
import type { RemoteAsk, RemoteHost, RemoteHostEdit, RemoteListing, RemoteProbe } from "./remote";
import type { CloudShareOffer, DeviceSelf, ShareOffer, SharePort, ShareStatus } from "./share";
@@ -41,6 +42,10 @@ export interface TreeSession {
// pane rather than opening a second writer for one file.
runtimeId?: string;
archived?: boolean;
+ // The kernel is the authority for pins.
+ pinned?: boolean;
+ // Unix ms of an automatic archive, absent for a manual one.
+ autoArchivedAt?: number;
// A turn finished since the person last looked. Absent means seen.
unread?: boolean;
// Conflict-recovery copies of this same conversation. A save that keeps
@@ -90,16 +95,48 @@ export interface TreeWorkspace {
// True when the user chose this folder. A window always has a root — the one
// it happened to launch in — and only this tells that apart from a project.
remembered?: boolean;
+ // Every conversation the folder lists, loaded or not, and how many of those
+ // are archived. `sessions` is only the newest of them; a kernel that predates
+ // paging sends neither, and then `sessions` is all there is.
+ total?: number;
+ archived?: number;
+ // How many rows the request's scope and word match that `sessions` does not
+ // carry. Absent on a kernel that predates it, and then only `total` says.
+ remaining?: number;
sessions: TreeSession[];
}
+// How much of each folder tree() builds: `limit` for all of them (0 is every
+// row), `limits` overriding it per folder root.
+export type TreeScope = "" | "active" | "archived";
+
+// `scope` says which rows the window counts (active ones, only the archived, or
+// every row) and `q` keeps them to those whose title or name contains it.
+export interface TreeWindow {
+ limit?: number;
+ limits?: Record;
+ scope?: TreeScope;
+ q?: string;
+}
+
+export function treeQuery(window: TreeWindow | undefined): string {
+ const q = new URLSearchParams();
+ if (window?.limit !== undefined) q.set("limit", String(window.limit));
+ if (window?.limits && Object.keys(window.limits).length > 0) q.set("limits", JSON.stringify(window.limits));
+ if (window?.scope) q.set("scope", window.scope);
+ if (window?.q?.trim()) q.set("q", window.q.trim());
+ const s = q.toString();
+ return s ? `?${s}` : "";
+}
+
// HubPort is the window's view of the kernel: which panes it drives and which
// folders it can open. One AgentPort hangs off each pane.
export interface HubPort extends SharePort {
runtimes(): Promise;
open(req: { root?: string; sessionPath?: string }): Promise;
close(id: string): Promise;
- tree(): Promise;
+ tree(window?: TreeWindow): Promise;
+ onTreeChanged(listener: () => void): () => void;
addWorkspace(path: string): Promise;
removeWorkspace(path: string): Promise;
/** Show a listed project's folder in the system file manager. */
@@ -107,6 +144,11 @@ export interface HubPort extends SharePort {
moveWorkspace(path: string, direction: -1 | 1): Promise;
removeSession(path: string): Promise;
archiveSession(path: string, archived: boolean): Promise;
+ // Tells the kernel which conversations automatic archiving must leave alone.
+ pinSession(path: string, pinned: boolean): Promise;
+ // Adds pins made before the kernel kept them. The first call also opens the
+ // gate: until then the kernel archives nothing on its own.
+ syncPins(paths: string[]): Promise;
renameSession(path: string, title: string): Promise;
exportSession(path: string): Promise<{ name: string; content: string }>;
importLegacySessions(path: string, workspace: string): Promise;
@@ -150,6 +192,8 @@ export interface HubPort extends SharePort {
// host-only action.
hostCapabilities(): Promise;
pickFolder(): Promise;
+ // Machine settings resolve a local owner; workspace reads can pin a runtime.
+ machinePort(runtimeId?: string): AgentPort;
// Absolute paths of every file dropped anywhere on the window. It belongs
// here rather than on a pane because the window has one of it: the shell
// registers its drop listener once and ignores a second call, so a per-pane
@@ -167,6 +211,7 @@ export class SseHub implements HubPort {
// Bindings the shell exposes are pane-independent; this reaches them without
// pretending to belong to a runtime.
private readonly shell = new SsePort();
+ private readonly settings = new Map();
// Questions seen this session, kept so an answer can carry the identities the
// kernel will check it against.
private readonly asked = new Map();
@@ -228,10 +273,15 @@ export class SseHub implements HubPort {
async close(id: string) {
await this.post(`/runtimes/${encodeURIComponent(id)}/close`);
this.ports.delete(id);
+ this.settings.delete(id);
}
- tree() {
- return this.get("/tree");
+ onTreeChanged(listener: () => void) {
+ return watchTree(listener);
+ }
+
+ tree(window?: TreeWindow) {
+ return this.get("/tree" + treeQuery(window));
}
addWorkspace(path: string) {
@@ -259,6 +309,14 @@ export class SseHub implements HubPort {
await this.post("/tree/sessions/archive", { path, archived });
}
+ async pinSession(path: string, pinned: boolean) {
+ await this.post("/tree/sessions/pin", { path, pinned });
+ }
+
+ async syncPins(paths: string[]) {
+ await this.post("/tree/sessions/pins", { paths });
+ }
+
async renameSession(path: string, title: string) {
await this.post("/tree/sessions/rename", { path, title });
}
@@ -478,6 +536,15 @@ export class SseHub implements HubPort {
await this.post("/device/leave", {});
}
+ machinePort(runtimeId = ""): AgentPort {
+ const held = this.settings.get(runtimeId);
+ if (held) return held;
+ const base = runtimeId ? `/host/settings/runtime/${encodeURIComponent(runtimeId)}` : "/host/settings";
+ const port = new SsePort(base);
+ this.settings.set(runtimeId, port);
+ return port;
+ }
+
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
diff --git a/desktop/frontend-next/src/port/look.ts b/desktop/frontend-next/src/port/look.ts
index ee53ee0b36..d098697a76 100644
--- a/desktop/frontend-next/src/port/look.ts
+++ b/desktop/frontend-next/src/port/look.ts
@@ -35,6 +35,7 @@ export interface ThemePack {
background?: ThemeBackground;
sky?: ThemeSky;
hasPreview?: boolean;
+ removable?: boolean;
// Tokens the kernel dropped and why: an unknown name, or a value that could
// not be let into a stylesheet. The pack still loads without them.
warnings?: string[];
diff --git a/desktop/frontend-next/src/port/machine-settings.test.ts b/desktop/frontend-next/src/port/machine-settings.test.ts
new file mode 100644
index 0000000000..79016fa79a
--- /dev/null
+++ b/desktop/frontend-next/src/port/machine-settings.test.ts
@@ -0,0 +1,48 @@
+// @vitest-environment jsdom
+import { afterEach, expect, it, vi } from "vitest";
+import { SseHub } from "./hub";
+
+afterEach(() => vi.unstubAllGlobals());
+
+it("keeps machine reads and writes independent of runtime IDs and reconnects", async () => {
+ const fetcher = vi.fn(async (_url: string, _init?: RequestInit) => new Response(JSON.stringify({ memories: [] })));
+ vi.stubGlobal("fetch", fetcher);
+ const hub = new SseHub();
+ const machine = hub.machinePort();
+ hub.portFor({ id: "r1", base: "/rt/r1", root: "/remote", name: "Remote", host: "gpu" });
+ await machine.memories();
+ await machine.forgetMemory("local-fact");
+ hub.portFor({ id: "r2", base: "/rt/r2", root: "/local", name: "Local" });
+ expect(hub.machinePort()).toBe(machine);
+ await machine.memories();
+ expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
+ "/host/settings/memory", "/host/settings/memory/forget", "/host/settings/memory",
+ ]);
+});
+
+
+it("pins workspace reads and mutations to the same local owner", async () => {
+ const fetcher = vi.fn(async (_url: string) => new Response(JSON.stringify({ memories: [], revisions: [], models: [] })));
+ vi.stubGlobal("fetch", fetcher);
+ const hub = new SseHub();
+ const local = hub.machinePort("r2");
+ expect(hub.machinePort("r2")).toBe(local);
+ expect(hub.machinePort("r1")).not.toBe(local);
+ expect(hub.machinePort()).not.toBe(local);
+ await local.memories();
+ await local.forgetMemory("fact");
+ await local.memoryRevisions("fact");
+ await local.restoreMemory("fact", 1);
+ await local.models("all");
+ await local.setDefaultModel("plugin/project/cloud/chat");
+ expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
+ "/host/settings/runtime/r2/memory", "/host/settings/runtime/r2/memory/forget",
+ "/host/settings/runtime/r2/memory/revisions?name=fact", "/host/settings/runtime/r2/memory/restore",
+ "/host/settings/runtime/r2/models?answers=all", "/host/settings/runtime/r2/default-model",
+ ]);
+ fetcher.mockResolvedValueOnce(new Response("refused", { status: 500 }));
+ await expect(hub.close("r2")).rejects.toThrow();
+ expect(hub.machinePort("r2")).toBe(local);
+ await hub.close("r2");
+ expect(hub.machinePort("r2")).not.toBe(local);
+});
diff --git a/desktop/frontend-next/src/port/mock.ts b/desktop/frontend-next/src/port/mock.ts
index 95689e30a2..ddec545151 100644
--- a/desktop/frontend-next/src/port/mock.ts
+++ b/desktop/frontend-next/src/port/mock.ts
@@ -1,6 +1,6 @@
import type { PlanAction } from "./session";
import { HttpError } from "./port";
-import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
+import type { AccountState, AgentPort, Completion, CompletionItem, DeviceGrant, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import { MockWorkspace } from "./mock_workspace";
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
@@ -187,6 +187,14 @@ export class MockPort extends MockWorkspace implements AgentPort {
async versionNotes(version: string): Promise {
return { version, markdown: "", cached: false };
}
+ async studioLogs(): Promise {
+ return {
+ dir: "C:\\Users\\demo\\AppData\\Roaming\\Reasonix Studio\\io.reasonix.studio.1a2b3c\\logs",
+ crashDir: "C:\\Users\\demo\\AppData\\Roaming\\Reasonix Studio\\io.reasonix.studio.1a2b3c\\logs\\crash",
+ crashes: [{ name: "host-20261010T230806Z-2.31.0-4412.log", at: "2026-10-10T23:08:06Z", bytes: 8421 }],
+ truncated: false,
+ };
+ }
async acknowledgeLaunchHealth(): Promise {} // booted from no update
async goToVersion(): Promise {
throw new Error("演示模式不会真的安装版本");
@@ -276,6 +284,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
{ label: "/review", insert: "/review ", hint: "复核这一轮改动,给出严重度分级", kind: "subagent" },
{ label: "/init", insert: "/init ", hint: "为这个仓库生成一份项目说明", kind: "skill" },
{ label: "/security-review", insert: "/security-review ", hint: "只读地过一遍安全面", kind: "subagent" },
+ { label: "/mcp__docs__summarize", insert: "/mcp__docs__summarize ", hint: "总结一份文档", kind: "prompt",
+ args: [{ name: "topic", description: "要总结的主题", required: true }, { name: "length", description: "篇幅,如 short 或 long" }] },
];
async refinePrompt(draft: string): Promise {
@@ -395,6 +405,10 @@ export class MockPort extends MockWorkspace implements AgentPort {
return this.prompts.map((prompt, i) => ({ turn: i, prompt, files: this.prompts.length > 1 ? 3 : 0, msgIndex: mockMsgIndex(i + 1) }));
}
+ async branchAt(turn: number): Promise {
+ return `/mock/branch-${turn}.jsonl`;
+ }
+
// The second prompt onwards is scripted to have run bash, so mock mode can
// show the consent stage the real kernel demands on partial coverage.
async prepareRewind(turn: number, scope: RewindScope): Promise {
@@ -536,7 +550,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
// The fixture has a window with an icon, because the state worth designing
// is the one where both switches mean something.
- private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true };
+ private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true, feedbackReply: true, sound: false };
async notifyPrefs(): Promise {
return { ...this.notifications };
@@ -576,6 +590,8 @@ export class MockPort extends MockWorkspace implements AgentPort {
return { id: "mock", target: "mock", url, title: url, active: true };
}
+ async browserClose(_tab: string): Promise {}
+
async queue(): Promise {
return {
revision: this.queueRevision,
diff --git a/desktop/frontend-next/src/port/mock_boundary.ts b/desktop/frontend-next/src/port/mock_boundary.ts
index 003942d249..7b7ae0e53a 100644
--- a/desktop/frontend-next/src/port/mock_boundary.ts
+++ b/desktop/frontend-next/src/port/mock_boundary.ts
@@ -1,5 +1,6 @@
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
-import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
+import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
+import { HttpError } from "./http_error";
import { MockShell } from "./mock_shell";
// The boundary half of the fixture: what the agent is refused outright, and how
@@ -95,6 +96,38 @@ export class MockBoundary extends MockShell {
return { ...this.jail };
}
+ private roots: WorkspaceRoots = { primary: "/Users/you/code/site", extra: [] };
+
+ // The kernel's refusals, by the same codes: a path that is not absolute, one
+ // already inside the workspace, and one it will not grant.
+ private refuseRoot(code: string): never {
+ throw new HttpError(400, "this folder cannot be added", { code });
+ }
+
+ async workspaceRoots(): Promise {
+ return { ...this.roots, extra: [...this.roots.extra] };
+ }
+
+ async addWorkspaceRoot(path: string): Promise {
+ const dir = path.trim();
+ if (!/^([a-zA-Z]:)?[\\/]/.test(dir)) this.refuseRoot("workspace_roots.not_directory");
+ if (dir === this.roots.primary || dir.startsWith(this.roots.primary + "/")) this.refuseRoot("workspace_roots.covered_by_primary");
+ if (dir === "/" || dir === "/Users/you") this.refuseRoot("workspace_roots.too_broad");
+ if (dir.includes(".ssh")) this.refuseRoot("workspace_roots.protected");
+ if (dir.startsWith("/var/root")) this.refuseRoot("workspace_roots.not_owned");
+ if (dir === "/tmp" || this.roots.extra.some((p) => dir.startsWith(p + "/"))) this.refuseRoot("workspace_roots.overlaps_write_root");
+ // The kernel records the resolved path, not the spelling: a trailing slash
+ // goes and a symlink is followed.
+ const resolved = (dir.length > 1 ? dir.replace(/[\\/]+$/, "") : dir).replace("/Users/you/link", "/Users/you/real");
+ if (!this.roots.extra.includes(resolved)) this.roots = { ...this.roots, extra: [...this.roots.extra, resolved] };
+ return this.workspaceRoots();
+ }
+
+ async removeWorkspaceRoot(path: string): Promise {
+ this.roots = { ...this.roots, extra: this.roots.extra.filter((p) => p !== path) };
+ return this.workspaceRoots();
+ }
+
async browserTools(): Promise {
return { ...this.browser };
}
@@ -104,6 +137,18 @@ export class MockBoundary extends MockShell {
return { ...this.browser };
}
+ private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" };
+
+ async autoArchive(): Promise {
+ return { ...this.archive };
+ }
+
+ async saveAutoArchive(s: Pick): Promise {
+ if (s.days < 1 || s.days > 3650) throw new Error("auto archive setting out of range");
+ this.archive = { ...this.archive, ...s };
+ return { ...this.archive };
+ }
+
private currency: DisplayCurrencySettings = { mode: "auto", path: "/Users/you/.reasonix/config.toml" };
async displayCurrency(): Promise {
@@ -115,11 +160,55 @@ export class MockBoundary extends MockShell {
return { ...this.currency };
}
+ private changedFiles: ChangedFilesSettings = { show: true, path: "/Users/you/.reasonix/config.toml" };
+
+ async changedFilesSettings(): Promise {
+ return { ...this.changedFiles };
+ }
+
+ async saveChangedFilesSettings(show: boolean): Promise {
+ this.changedFiles = { ...this.changedFiles, show };
+ return { ...this.changedFiles };
+ }
+
+ private foldWork: FoldWorkSettings = { fold: false, path: "/Users/you/.reasonix/config.toml" };
+
+ async foldWorkSettings(): Promise {
+ return { ...this.foldWork };
+ }
+
+ async saveFoldWorkSettings(fold: boolean): Promise {
+ this.foldWork = { ...this.foldWork, fold };
+ return { ...this.foldWork };
+ }
+
private watch: ProgressWatchSettings = {
pause: false, rounds: 20, tokenMultiple: 8, defaultRounds: 20, defaultTokenMultiple: 8,
path: "/Users/you/.reasonix/config.toml",
};
+ private ask: AskTimeoutSettings = { seconds: 0, defaultSeconds: 300, minSeconds: 10, maxSeconds: 86400, path: "/Users/you/.reasonix/config.toml" };
+
+ async askTimeout(): Promise {
+ return { ...this.ask };
+ }
+
+ async saveAskTimeout(seconds: number): Promise {
+ if (seconds !== 0 && (seconds < this.ask.minSeconds || seconds > this.ask.maxSeconds)) throw new Error("ask timeout out of range");
+ this.ask = { ...this.ask, seconds };
+ return { ...this.ask };
+ }
+ private receipt: ReceiptSettings = { show: false, path: "/Users/you/.reasonix/config.toml" };
+
+ async receiptSettings(): Promise {
+ return { ...this.receipt };
+ }
+
+ async saveReceiptSettings(show: boolean): Promise {
+ this.receipt = { ...this.receipt, show };
+ return { ...this.receipt };
+ }
+
async progressWatch(): Promise {
return { ...this.watch };
}
diff --git a/desktop/frontend-next/src/port/mock_feedback.ts b/desktop/frontend-next/src/port/mock_feedback.ts
index 1d80e32595..9f65719a66 100644
--- a/desktop/frontend-next/src/port/mock_feedback.ts
+++ b/desktop/frontend-next/src/port/mock_feedback.ts
@@ -41,6 +41,7 @@ const SEEDED: FeedbackItem[] = [
// dev page can show every failure without a kernel that will produce it.
const FAULT = "rx-mock-feedback-fault";
const REPLY_FAULT = "rx-mock-feedback-reply-fault";
+const LATE_REPLY = "rx-mock-feedback-late-reply";
function fault(key = FAULT): string {
try {
@@ -118,7 +119,9 @@ export class MockFeedback extends MockCommit {
async myFeedback(): Promise {
const code = fault();
if (code === "mine_error") throw new HttpError(502, "unavailable", { code: FEEDBACK_CODE.unavailable, error: "unavailable" });
- const items = [...this.filed, ...this.rows].map((i) => {
+ const late = fault(LATE_REPLY) !== "";
+ const items = [...this.filed, ...this.rows].map((row) => {
+ const i = late && row.receipt === "FB-9B4D-1XM6" ? { ...row, replies: [msg(41, "maintainer", "Settings > Model services.", 0)] } : row;
const unreadReplies = i.replies.filter((r) => r.author === "maintainer" && r.id > (this.seen.get(i.receipt) ?? 0)).length;
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
});
@@ -126,6 +129,12 @@ export class MockFeedback extends MockCommit {
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
}
+ announced = 0;
+
+ async announceFeedbackReply(): Promise {
+ this.announced += 1;
+ }
+
async replyFeedback(receipt: string, body: string): Promise {
const code = fault(REPLY_FAULT);
if (code === "feedback.invalid") {
diff --git a/desktop/frontend-next/src/port/mock_hub.ts b/desktop/frontend-next/src/port/mock_hub.ts
index e0864e7c26..af1b47a848 100644
--- a/desktop/frontend-next/src/port/mock_hub.ts
+++ b/desktop/frontend-next/src/port/mock_hub.ts
@@ -1,5 +1,5 @@
import type { AgentPort } from "./port";
-import type { HubPort, RuntimeView, TreeWorkspace } from "./hub";
+import type { HubPort, RuntimeView, TreeWindow, TreeWorkspace } from "./hub";
import type { RemoteHost, RemoteListing, RemoteProbe } from "./remote";
import type { ShareStatus } from "./share";
import { MockPort } from "./mock";
@@ -10,6 +10,7 @@ import { MockPort } from "./mock";
export class MockHub implements HubPort {
private readonly ports = new Map();
private readonly machine = { configured: false };
+ private readonly settings = new MockPort();
private readonly views: RuntimeView[] = [
{ id: "r1", base: "", root: "~/projects/DeepSeek-Reasonix", name: "DeepSeek-Reasonix", sessionPath: "/sessions/mock.jsonl" },
];
@@ -158,7 +159,9 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
- tree() {
+ onTreeChanged(_listener: () => void) { return () => {}; }
+
+ tree(_window?: TreeWindow) {
const open = new Map(this.views.filter((v) => v.sessionPath).map((v) => [v.sessionPath!, v.id]));
return Promise.resolve(
this.roots.map((root, i) => ({
@@ -211,6 +214,14 @@ export class MockHub implements HubPort {
return Promise.resolve();
}
+ pinSession(_path: string, _pinned: boolean) {
+ return Promise.resolve();
+ }
+
+ syncPins(_paths: string[]) {
+ return Promise.resolve();
+ }
+
renameSession(_path: string, _title: string) {
return Promise.resolve();
}
@@ -292,6 +303,13 @@ export class MockHub implements HubPort {
return Promise.resolve({ ...this.share });
}
+ machinePort(runtimeId?: string): AgentPort {
+ const local = this.views.find((rt) => rt.id === runtimeId && !rt.host);
+ if (local) return this.portFor(local);
+ this.settings.machine = this.machine;
+ return this.settings;
+ }
+
portFor(rt: RuntimeView): AgentPort {
const held = this.ports.get(rt.id);
if (held) return held;
diff --git a/desktop/frontend-next/src/port/mock_provider.ts b/desktop/frontend-next/src/port/mock_provider.ts
index d8faa5c1e9..4e3f1d2441 100644
--- a/desktop/frontend-next/src/port/mock_provider.ts
+++ b/desktop/frontend-next/src/port/mock_provider.ts
@@ -3,10 +3,13 @@ import type {
ModelLimit,
Protocol,
ProviderCheck,
+ ProviderRemoval,
ProviderEdit,
ProviderEntry,
ProviderModelCheck,
ProviderModelCheckRequest,
+ ProviderPreset,
+ ProviderPresetInstall,
ProviderProbe,
} from "./port";
import { MockBoundary } from "./mock_boundary";
@@ -83,6 +86,19 @@ export class MockProvider extends MockBoundary {
];
}
+ async providerPresets(): Promise {
+ return [
+ { id: "kimi-cn", label: "Kimi CN API", kind: "openai", baseUrl: "https://api.moonshot.cn/v1" },
+ { id: "kimi-global", label: "Kimi Global API", kind: "openai", baseUrl: "https://api.moonshot.ai/v1" },
+ { id: "stepfun", label: "StepFun", kind: "openai", baseUrl: "https://api.stepfun.com/v1" },
+ { id: "stepfun-anthropic", label: "StepFun Anthropic", kind: "anthropic", baseUrl: "https://api.stepfun.com/anthropic" },
+ ];
+ }
+
+ async installProviderPreset(req: ProviderPresetInstall): Promise<{ names: string[] }> {
+ return { names: [req.name?.trim() || req.presetId] };
+ }
+
// The opening sequence is the only way into the fixture, and it advances on
// what the probe found: refusing here leaves the demo on its first screen
// with no way past, so it answers with data like every other face does.
@@ -167,7 +183,14 @@ export class MockProvider extends MockBoundary {
);
}
- async removeProvider(): Promise {}
+ // The relay stands for a service other roles lean on, so removing it shows
+ // the report; the rest go without a word.
+ async removeProvider(name: string): Promise {
+ this.sources = this.sources.filter((e) => e.name !== name);
+ return name === "myrelay"
+ ? { movedTo: "deepseek", moved: ["default", "planner"], cleared: ["vision", "advisor"] }
+ : { movedTo: "", moved: [], cleared: [] };
+ }
}
// A gateway answering at the scale the connection panel has to survive: the
diff --git a/desktop/frontend-next/src/port/mock_theme.ts b/desktop/frontend-next/src/port/mock_theme.ts
index 036121aee3..bf090cb2ab 100644
--- a/desktop/frontend-next/src/port/mock_theme.ts
+++ b/desktop/frontend-next/src/port/mock_theme.ts
@@ -9,12 +9,14 @@ export class MockTheme extends MockNetwork {
private slots: Record = {};
+ private removed = new Set();
+
// Two packs so the picker has something to switch between; the fixture is
// where the mapping gets exercised without a Go process.
async themes(): Promise {
- return [
+ const packs: ThemePack[] = [
{
- id: "dusk", name: "Dusk", author: "fixture", active: this.activeTheme === "dusk",
+ id: "dusk", name: "Dusk", author: "fixture", removable: true, active: this.activeTheme === "dusk",
tokens: {
light: { bg: "#F6F3EE", bgSoft: "#FBF9F5", panel: "#FFFFFF", border: "#DDD5C8", fg: "#1B1814", fgDim: "#5F564A", accent: "#8A5A2B" },
dark: { bg: "#0F0D0B", bgSoft: "#141110", panel: "#1B1715", border: "#332C26", fg: "#EFE9E1", fgDim: "#9A8F82", accent: "#D89B5A" },
@@ -32,12 +34,18 @@ export class MockTheme extends MockNetwork {
},
},
];
+ return packs.filter((p) => !this.removed.has(p.id));
}
async activateTheme(id: string) {
this.activeTheme = id;
}
+ async removeTheme(id: string) {
+ if (this.activeTheme === id) this.activeTheme = "";
+ this.removed.add(id);
+ }
+
async importTheme(files: File[]): Promise {
const name = files[0]?.name.replace(/\.(zip|json)$/i, "") || "imported";
return { pack: { id: name, name, tokens: { light: { bg: "#FFFFFF" }, dark: { bg: "#000000" } } } };
diff --git a/desktop/frontend-next/src/port/model.ts b/desktop/frontend-next/src/port/model.ts
index 4d99418e07..07d9a93c4d 100644
--- a/desktop/frontend-next/src/port/model.ts
+++ b/desktop/frontend-next/src/port/model.ts
@@ -14,6 +14,16 @@ export interface CompletionItem {
// builtin | command | skill | subagent | prompt | file | dir | resource.
// Empty on argument values, which are not things.
kind?: string;
+ // The arguments an MCP prompt declares, in order.
+ args?: CompletionArg[];
+ // Declarations the kernel left out of args to keep the row short.
+ moreArgs?: number;
+}
+
+export interface CompletionArg {
+ name: string;
+ description?: string;
+ required?: boolean;
}
export interface Completion {
@@ -39,6 +49,7 @@ export interface ModelEntry {
// What the user renamed provider to; provider stays the identity.
displayName?: string;
model: string;
+ modelAlias?: string;
kind?: string;
active?: boolean;
default?: boolean;
diff --git a/desktop/frontend-next/src/port/port.ts b/desktop/frontend-next/src/port/port.ts
index 4406909899..e75520881c 100644
--- a/desktop/frontend-next/src/port/port.ts
+++ b/desktop/frontend-next/src/port/port.ts
@@ -22,7 +22,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
import type { SkillCatalog, SkillEntry } from "./skill";
-import type { UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
+import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
// The port is one contract; its subjects each keep their own file, the way the
@@ -35,15 +35,15 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
- VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
+ StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
import type { Appearance, ThemeImport, ThemePack } from "./look";
-import type { BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings } from "./boundary";
-import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
-export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup } from "./provider";
+import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
+import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
+export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
@@ -146,6 +146,8 @@ export interface NotifyPrefs {
turnDone: boolean;
approval: boolean;
ask: boolean;
+ feedbackReply: boolean;
+ sound: boolean;
}
export interface TrayPrefs {
@@ -258,15 +260,32 @@ export interface AgentPort {
revokeRememberedProjectRule(rule: string): Promise;
sandbox(): Promise;
saveSandbox(s: SandboxSettings): Promise;
+ // Per-project grants kept in the user's home, never in the checkout. Adding
+ // or removing one rebuilds the runtime, so both are refused with
+ // workspace_roots.busy while work is running.
+ workspaceRoots(): Promise;
+ addWorkspaceRoot(path: string): Promise;
+ removeWorkspaceRoot(path: string): Promise;
browserTools(): Promise;
// Writes the user file, then rebuilds: the tools are bound while assembling.
saveBrowserTools(enabled: boolean): Promise;
displayCurrency(): Promise;
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise;
+ changedFilesSettings(): Promise;
+ saveChangedFilesSettings(show: boolean): Promise;
+ foldWorkSettings(): Promise;
+ saveFoldWorkSettings(fold: boolean): Promise;
progressWatch(): Promise;
// Applies to the running turn at its next round; no rebuild.
saveProgressWatch(s: Pick): Promise;
+ askTimeout(): Promise;
+ saveAskTimeout(seconds: number): Promise;
+ autoArchive(): Promise;
+ // Read by the kernel's background sweep on each pass; no rebuild.
+ saveAutoArchive(s: Pick): Promise;
+ receiptSettings(): Promise;
+ saveReceiptSettings(show: boolean): Promise;
// null in a browser tab: there is no window to keep running.
trayPrefs(): Promise;
setTrayPrefs(icon: boolean, closeToTray: boolean): Promise;
@@ -317,6 +336,8 @@ export interface AgentPort {
// The wire formats a source may be saved as. Read from the kernel so a
// protocol added there reaches this panel without a frontend release.
protocols(): Promise;
+ providerPresets(): Promise;
+ installProviderPreset(req: ProviderPresetInstall): Promise<{ names: string[] }>;
// Asks an endpoint what it is. Writes nothing — the answer is shown for
// confirmation, because only the person holding the key knows what they
// bought.
@@ -339,11 +360,13 @@ export interface AgentPort {
// acknowledgement that closes it out.
welcomeSeen(): Promise;
markWelcomed(): Promise;
- removeProvider(name: string): Promise;
+ removeProvider(name: string): Promise;
versions(): Promise;
pinVersion(version: string): Promise;
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
versionNotes(version: string, retry?: boolean): Promise;
+ // Where this install keeps its logs, and the crash files waiting there.
+ studioLogs(): Promise;
// Installs a published version, forward or back — the same call either way,
// because a rollback that took a second code path would be the less-tested
// one. Resolves only on failure: a success ends with the process handing over
@@ -381,6 +404,9 @@ export interface AgentPort {
replyFeedback(receipt: string, body: string): Promise;
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
feedbackSeen(receipt: string, upTo: number): Promise;
+ // Asks the window to raise the neutral "new reply" notification; the kernel
+ // answers for the switches. Rejects where there is no window to raise it.
+ announceFeedbackReply(): Promise;
workspaces(): Promise;
// Rebuilds the whole runtime against another folder. The conversation does
// not come along, so the caller has to reload the transcript afterwards.
@@ -430,6 +456,9 @@ export interface AgentPort {
// the user agreed to.
prepareRewind(turn: number, scope: RewindScope): Promise;
commitRewind(planId: string): Promise;
+ // Copies the conversation up to the end of a turn into a new session and
+ // answers with its path; this session stays where it is.
+ branchAt(turn: number, name: string): Promise;
// Reverses a committed rewind. Only reachable while the caller still holds the
// transaction id the commit returned.
undoRewind(transactionId: string): Promise;
@@ -516,6 +545,7 @@ export interface AgentPort {
// the agent can read and drive; the window keeping its own would be a page
// neither could hand to the other.
browserOpen(url: string, newTab: boolean): Promise;
+ browserClose(tab: string): Promise;
// The entry's full text. preview is cut to a manifest-sized line, so editing
// against it would silently shorten what the user actually said.
readQueued(itemId: string): Promise;
@@ -567,6 +597,8 @@ export interface AgentPort {
surfaceSlots(): Promise>;
assignSurface(surface: string, slot: string): Promise;
activateTheme(id: string): Promise;
+ // Only a pack in the user's own library; the kernel refuses any other.
+ removeTheme(id: string): Promise;
// A zip, or a pack's loose files; the kernel keeps what a pack can hold.
importTheme(files: File[]): Promise;
// Shows where installed packs live and answers that directory.
diff --git a/desktop/frontend-next/src/port/provider.ts b/desktop/frontend-next/src/port/provider.ts
index 6c5263cc8b..bf26f0d8c2 100644
--- a/desktop/frontend-next/src/port/provider.ts
+++ b/desktop/frontend-next/src/port/provider.ts
@@ -9,6 +9,7 @@ export interface ProviderEntry {
kind: string;
baseUrl: string;
models: string[];
+ modelAliases?: Record;
default: string;
hasKey: boolean;
keyEnv?: string;
@@ -25,6 +26,9 @@ export interface ProviderEntry {
// at all; webSearch whether it is on. They differ between an account's doors.
canWebSearch?: boolean;
webSearch?: boolean;
+ // False where the entry's protocol declares no model listing, so the editor
+ // offers no control to read one. Absent is a kernel that predates the field.
+ canListModels?: boolean;
// Whether thinking/reasoning_effort may go on the wire. canSetThinking is
// false where the protocol never carries them, so the switch appears only
// where a relay can actually reject the request over it.
@@ -96,6 +100,23 @@ export interface Protocol {
effortUnder?: string[];
}
+// A curated starting point the kernel offers. Choosing one creates the whole
+// declared entry — catalog, window, vision models, thinking shape — in the
+// kernel; the person supplies only a name and a key.
+export interface ProviderPreset {
+ id: string;
+ label: string;
+ kind: string;
+ baseUrl: string;
+}
+
+export interface ProviderPresetInstall {
+ presetId: string;
+ // Empty takes the preset's own name, made unique by the kernel.
+ name?: string;
+ apiKey: string;
+}
+
// What an endpoint turned out to be. Every field is a guess the user confirms
// before anything is written — a model list cannot prove which protocol a
// gateway speaks, only which ones it answers.
@@ -125,6 +146,12 @@ export interface ProviderProbe {
// What re-probing a saved provider found. A failure carries `code`, the dotted
// identity the add flow's refusals use, because "401" and "no chat models" send
// the user to different fixes; the endpoint's own words ride along as `detail`.
+export interface ProviderRemoval {
+ movedTo: string;
+ moved: string[];
+ cleared: string[];
+}
+
export interface ProviderCheck {
ok: boolean;
kind?: string;
@@ -140,6 +167,8 @@ export interface ProviderCheck {
ambiguous?: boolean;
noProxy?: boolean;
code?: string;
+ // The host a refused redirect pointed at.
+ target?: string;
// Only the numbers the code's sentence needs: `status`, `count`.
params?: Record;
httpStatus?: number;
@@ -155,6 +184,10 @@ export type ProviderModelCheckReason =
| "rejected"
| "network"
| "timeout"
+ // Timed out against the machine's own address: the engine did not answer, not the network.
+ | "local_timeout"
+ // The address redirected to another host; the detail names it.
+ | "redirect_refused"
// The endpoint answered chat and refused a tools array. Established by
// sending the same request again without one, not by reading the refusal.
| "tools";
@@ -183,6 +216,10 @@ export interface ProviderModelCheckRequest {
// Changing a source that already exists: everything else on the entry stays.
export interface ProviderEdit {
name: string;
+ // Omitted preserves aliases; blank or missing values reset listed models.
+ modelAliases?: Record;
+ // Omitted leaves the wire format alone.
+ kind?: string;
baseUrl?: string;
// Empty keeps the stored key.
apiKey?: string;
diff --git a/desktop/frontend-next/src/port/session.ts b/desktop/frontend-next/src/port/session.ts
index b128bf0b60..b7732273aa 100644
--- a/desktop/frontend-next/src/port/session.ts
+++ b/desktop/frontend-next/src/port/session.ts
@@ -1,5 +1,5 @@
import type { ModelMode } from "./model";
-import type { Via } from "./wire";
+import type { StoredAttachment, Via } from "./wire";
// A conversation and the states it passes through: what is running, what it
// cost, and the points it can be wound back to.
// GET /history: the event stream is live-only, so a reload rebuilds from these.
@@ -23,7 +23,10 @@ export interface HistoryMessage {
modelRef?: string;
reasoning?: string;
thoughtMs?: number; // kernel-measured; absent on turns recorded before it was
+ workDurationMs?: number; // only on the answer that closed a turn
images?: number; // attachments on a user turn; an image-only one has no text
+ // The stored files a user message references, resolved by the kernel.
+ attachments?: StoredAttachment[];
toolCalls?: HistoryToolCall[];
toolCallId?: string;
toolName?: string;
diff --git a/desktop/frontend-next/src/port/sse.ts b/desktop/frontend-next/src/port/sse.ts
index dec6f61246..3b956594b9 100644
--- a/desktop/frontend-next/src/port/sse.ts
+++ b/desktop/frontend-next/src/port/sse.ts
@@ -1,5 +1,5 @@
import { PLAN_ACTIONS, type PlanAction } from "./session";
-import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
+import type { AccountState, AgentPort, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, StudioLogs, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
import { HttpError, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles } from "./port";
import { SseWorkspace } from "./sse_workspace";
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
@@ -248,6 +248,12 @@ export class SsePort extends SseWorkspace implements AgentPort {
return (await res.json()) as VersionNotes;
}
+ async studioLogs(): Promise {
+ const res = await fetch("/studio/logs", { credentials: "same-origin" });
+ if (!res.ok) await SsePort.fail("/studio/logs", res);
+ return (await res.json()) as StudioLogs;
+ }
+
async pinVersion(version: string): Promise {
const res = await fetch("/studio/pin", {
method: "POST",
@@ -491,6 +497,10 @@ export class SsePort extends SseWorkspace implements AgentPort {
return this.post0("/rewind/commit", { planId });
}
+ async branchAt(turn: number, name: string) {
+ return (await this.post0<{ path: string }>("/branch/at", { turn, name })).path;
+ }
+
undoRewind(transactionId: string) {
return this.post("/rewind/undo", { transactionId });
}
@@ -678,6 +688,9 @@ export class SsePort extends SseWorkspace implements AgentPort {
browserOpen(url: string, newTab: boolean) {
return this.post0("/browser/open", { url, newTab });
}
+ browserClose(tab: string) {
+ return this.post0("/browser/close", { tab });
+ }
async readQueued(itemId: string) {
const r = await this.get<{ envelope?: { displayText?: string } }>("/inbox/items/" + encodeURIComponent(itemId));
// Every stored entry has a body — the kernel refuses an empty one at
@@ -731,21 +744,6 @@ export class SsePort extends SseWorkspace implements AgentPort {
answers: answers.map((a) => ({ QuestionID: a.questionId, Selected: a.selected })),
});
}
- async uploadWallpaper(blob: Blob) {
- const buf = new Uint8Array(await blob.arrayBuffer());
- let bin = "";
- for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
- const res = await fetch(this.base + "/appearance/wallpaper", {
- method: "POST",
- headers: { "content-type": "application/json" },
- credentials: "same-origin",
- body: JSON.stringify({ mime: blob.type, data: btoa(bin) }),
- });
- const body = (await res.json().catch(() => ({}))) as Appearance & { error?: string };
- if (!res.ok) throw new Error(body.error || `/appearance/wallpaper: ${res.status}`);
- return body;
- }
-
async invokeExtensionAction(name: string) {
const out = await this.post0<{ message?: string }>("/extensions/action", { name });
return out.message ?? "";
diff --git a/desktop/frontend-next/src/port/sse_attach.test.ts b/desktop/frontend-next/src/port/sse_attach.test.ts
index e893965fcb..08371c52e8 100644
--- a/desktop/frontend-next/src/port/sse_attach.test.ts
+++ b/desktop/frontend-next/src/port/sse_attach.test.ts
@@ -31,6 +31,18 @@ describe("attach", () => {
expect(err.reason?.code).toBe("attachment.unreadable");
});
+ it("refuses a file past the kernel's limit before reading a byte of it", async () => {
+ const read = vi.fn(async () => new ArrayBuffer(0));
+ const fetched = vi.fn();
+ vi.stubGlobal("fetch", fetched);
+ const huge = { type: "application/zip", size: 600 * 1024 * 1024, arrayBuffer: read } as unknown as Blob;
+ const err = await new SsePort("", "r1").attach(huge, "modpack.mrpack").catch((e) => e);
+ expect(err).toBeInstanceOf(HttpError);
+ expect(err.reason).toMatchObject({ code: "attachment.too_large", params: { limit_mb: 25 } });
+ expect(read).not.toHaveBeenCalled();
+ expect(fetched).not.toHaveBeenCalled();
+ });
+
it("still returns the saved reference on success", async () => {
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => ({ path: "p", ref: "@p", image: false }) }));
expect(await new SsePort("", "r1").attach(new Blob(["x"]), "a.txt")).toEqual({ path: "p", ref: "@p", image: false });
diff --git a/desktop/frontend-next/src/port/sse_boundary.ts b/desktop/frontend-next/src/port/sse_boundary.ts
index c05ed929cd..8c044fbaaa 100644
--- a/desktop/frontend-next/src/port/sse_boundary.ts
+++ b/desktop/frontend-next/src/port/sse_boundary.ts
@@ -1,6 +1,6 @@
import { SseShell } from "./sse_shell";
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
-import type { DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings } from "./boundary";
+import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
// Where the agent may reach: the permission rules a call is matched against and
// the sandbox the shell runs in.
@@ -26,6 +26,15 @@ export class SseBoundary extends SseShell {
saveSandbox(s: SandboxSettings) {
return this.post0("/sandbox", s);
}
+ workspaceRoots() {
+ return this.get("/workspace/roots");
+ }
+ addWorkspaceRoot(path: string) {
+ return this.post0("/workspace/roots", { path });
+ }
+ removeWorkspaceRoot(path: string) {
+ return this.post0("/workspace/roots/remove", { path });
+ }
browserTools() {
return this.get("/browser-tools");
}
@@ -38,12 +47,42 @@ export class SseBoundary extends SseShell {
saveDisplayCurrency(mode: DisplayCurrencyMode) {
return this.post0("/display-currency", { mode });
}
+ changedFilesSettings() {
+ return this.get("/changed-files-settings");
+ }
+ saveChangedFilesSettings(show: boolean) {
+ return this.post0("/changed-files-settings", { show });
+ }
+ foldWorkSettings() {
+ return this.get("/fold-work-settings");
+ }
+ saveFoldWorkSettings(fold: boolean) {
+ return this.post0("/fold-work-settings", { fold });
+ }
progressWatch() {
return this.get("/progress-watch");
}
saveProgressWatch(s: Pick) {
return this.post0("/progress-watch", s);
}
+ autoArchive() {
+ return this.get("/auto-archive");
+ }
+ saveAutoArchive(s: Pick) {
+ return this.post0("/auto-archive", s);
+ }
+ askTimeout() {
+ return this.get("/ask-timeout");
+ }
+ saveAskTimeout(seconds: number) {
+ return this.post0("/ask-timeout", { seconds });
+ }
+ receiptSettings() {
+ return this.get("/receipt-settings");
+ }
+ saveReceiptSettings(show: boolean) {
+ return this.post0("/receipt-settings", { show });
+ }
configProblem() {
return this.get("/config/problem");
}
diff --git a/desktop/frontend-next/src/port/sse_feedback.ts b/desktop/frontend-next/src/port/sse_feedback.ts
index b1beb43ac7..6d60e0a68c 100644
--- a/desktop/frontend-next/src/port/sse_feedback.ts
+++ b/desktop/frontend-next/src/port/sse_feedback.ts
@@ -14,6 +14,9 @@ export class SseFeedback extends SseBackup {
replyFeedback(receipt: string, body: string) {
return this.post0("/feedback/" + encodeURIComponent(receipt) + "/reply", { body });
}
+ announceFeedbackReply() {
+ return this.post("/notifications/feedback-reply", {});
+ }
feedbackSeen(receipt: string, upTo: number) {
return this.post("/feedback/" + encodeURIComponent(receipt) + "/seen", { upTo });
}
diff --git a/desktop/frontend-next/src/port/sse_intake.ts b/desktop/frontend-next/src/port/sse_intake.ts
index 404b2cfe1a..aebc3a56e0 100644
--- a/desktop/frontend-next/src/port/sse_intake.ts
+++ b/desktop/frontend-next/src/port/sse_intake.ts
@@ -1,18 +1,17 @@
import { HttpError, type Attachment, type DroppedRef } from "./port";
import { SseHttp } from "./sse_http";
import { SseTheme } from "./sse_theme";
+import { attachmentLimitMb, base64Of, guardUpload, tooLarge } from "./upload";
// Where a file enters a turn: bytes the page holds, or paths the shell knows.
export class SseIntake extends SseTheme {
// JSON, not raw bytes: csrfGuard admits nothing else, and that guard is what
// stops a cross-site form posting here at all.
async attach(blob: Blob, name?: string) {
+ guardUpload(blob.size, attachmentLimitMb(blob.type, name), tooLarge("attachment.too_large", "limit_mb"));
let data: string;
try {
- const buf = new Uint8Array(await blob.arrayBuffer());
- let bin = "";
- for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
- data = btoa(bin);
+ data = await base64Of(blob);
} catch (e) {
const detail = String(e);
throw new HttpError(0, detail, { code: "attachment.unreadable", error: detail, params: { detail } });
diff --git a/desktop/frontend-next/src/port/sse_look.ts b/desktop/frontend-next/src/port/sse_look.ts
index e24746463f..9fb3376dd5 100644
--- a/desktop/frontend-next/src/port/sse_look.ts
+++ b/desktop/frontend-next/src/port/sse_look.ts
@@ -1,4 +1,5 @@
import { SseHttp } from "./sse_http";
+import { base64Of, guardUpload, tooLarge, UPLOAD_LIMIT_MB } from "./upload";
import type { Appearance } from "./port";
// The appearance half of the port. Chained the way the fixture's faces are, so
@@ -28,14 +29,12 @@ export class SseLook extends SseHttp {
// JSON, not raw bytes: csrfGuard admits nothing else, which is what stops a
// cross-site form from posting here at all.
async uploadWallpaper(blob: Blob) {
- const buf = new Uint8Array(await blob.arrayBuffer());
- let bin = "";
- for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
+ guardUpload(blob.size, UPLOAD_LIMIT_MB.wallpaper, tooLarge("wallpaper.too_large", "limit"));
const res = await fetch(this.base + "/appearance/wallpaper", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "same-origin",
- body: JSON.stringify({ mime: blob.type, data: btoa(bin) }),
+ body: JSON.stringify({ mime: blob.type, data: await base64Of(blob) }),
});
const body = (await res.json().catch(() => ({}))) as Appearance & { error?: string };
if (!res.ok) throw new Error(body.error || `/appearance/wallpaper: ${res.status}`);
diff --git a/desktop/frontend-next/src/port/sse_provider.ts b/desktop/frontend-next/src/port/sse_provider.ts
index 25c74d77c9..4db2320138 100644
--- a/desktop/frontend-next/src/port/sse_provider.ts
+++ b/desktop/frontend-next/src/port/sse_provider.ts
@@ -1,5 +1,5 @@
import { SseBoundary } from "./sse_boundary";
-import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe } from "./port";
+import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./port";
// Where models come from: the accounts, the protocols their endpoints answer,
// and what probing one found.
@@ -11,6 +11,14 @@ export class SseProvider extends SseBoundary {
return this.get("/providers/protocols");
}
+ providerPresets() {
+ return this.get("/providers/presets");
+ }
+
+ installProviderPreset(req: ProviderPresetInstall) {
+ return this.post0<{ names: string[] }>("/providers/presets/install", req);
+ }
+
// A refused key, a path the endpoint does not serve and an embedding-only
// gateway are three different fixes. post0 keeps the kernel's coded refusal
// on HttpError; a bare Error would flatten it back to one string.
@@ -43,7 +51,9 @@ export class SseProvider extends SseBoundary {
editProvider(edit: ProviderEdit) {
return this.post("/providers/edit", edit);
}
- removeProvider(name: string) {
- return this.post("/providers/remove", { name });
+ // 204 means nothing besides the service itself changed.
+ async removeProvider(name: string): Promise {
+ const body = await this.postMaybe