Merge remote-tracking branch 'origin/studio' into own/11761

# Conflicts:
#	internal/model/anthropic/anthropic.go
This commit is contained in:
yhh committed 2026-10-12 06:54:13 -07:00
commit 8931d3e612
1071 files changed
+43410 -7736

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+20 -13
View File
@@ -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
+3 -3
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+37 -36
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
+94 -15
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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"
+4 -4
View File
@@ -59,7 +59,7 @@
## ❤️Sponsor
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>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.</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">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 <a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">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 <a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">this link</a>.</td></tr></table>
## 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) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+4 -4
View File
@@ -57,7 +57,7 @@
## ❤️赞助商
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。通过<a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。通过<a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer">此链接</a>注册。</td></tr></table>
## 它是什么
@@ -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) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+3 -2
View File
@@ -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.
+1 -1
View File
@@ -420,7 +420,7 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-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/<task>/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. |
+21 -10
View File
@@ -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:
+145
View File
@@ -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)
}
@@ -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)
}
}
}
@@ -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.
+3 -3
View File
@@ -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)
+28 -12
View File
@@ -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
}
+59
View File
@@ -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)
}
@@ -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)
}
}
+534
View File
@@ -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])
}
})
}
}
+15 -20
View File
@@ -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)
}
}
}
+45 -10
View File
@@ -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, "\n<sub>agent run note: %v</sub>\n", r.runErr)
}
if line := analysisFailureLine(r); line != "" {
b.WriteString(line)
}
fmt.Fprintf(&b, "\n<sub>Pass = 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.</sub>\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)
}
+1 -1
View File
@@ -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 {
+14 -1
View File
@@ -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)
+4 -2
View File
@@ -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
}
+13 -8
View File
@@ -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.
+1
View File
@@ -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))
+26 -16
View File
@@ -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) {
+7 -2
View File
@@ -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)
}
}
+46
View File
@@ -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
}
+261
View File
@@ -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", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
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", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
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", "[email protected]",
"GIT_COMMITTER_NAME=t", "[email protected]")
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")
}
}
+1 -1
View File
@@ -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 }) {
+54 -6
View File
@@ -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"
}
+180
View File
@@ -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)
}
}
+26 -11
View File
@@ -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)
}
+23 -9
View File
@@ -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)
}
}
+21 -4
View File
@@ -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)
+10 -2
View File
@@ -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
+12 -2
View File
@@ -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)
}
}
+3 -2
View File
@@ -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)
+1 -1
View File
@@ -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 |
+1 -30
View File
@@ -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 <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
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 <file> against <file>.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.
-26
View File
@@ -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)
}
}
}
+33 -1
View File
@@ -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)
}
}
}
}
+22 -1
View File
@@ -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-<arch>.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 {
+54
View File
@@ -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)
}
}
-15
View File
@@ -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
}
-23
View File
@@ -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)
}
}
@@ -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
@@ -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)
}
+8 -5
View File
@@ -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
@@ -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)
}
}
+2 -2
View File
@@ -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)
}
-581
View File
@@ -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()
}
@@ -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")
}
}
-238
View File
@@ -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
}
@@ -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 })
}
@@ -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/<version>/
// 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/<ver>/, 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
}
@@ -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")
}
}
+9
View File
@@ -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"
]
}
}
+20
View File
@@ -96,6 +96,7 @@ mac:
linux:
target:
- deb
- rpm
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
@@ -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
+3 -2
View File
@@ -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": [
+7 -2
View File
@@ -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 };
@@ -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`);
}
+18 -18
View File
@@ -9,14 +9,14 @@ importers:
.:
devDependencies:
electron:
specifier: 44.5.1
version: 44.5.1([email protected])
specifier: 44.7.0
version: 44.7.0([email protected])
electron-builder:
specifier: ^26.15.3
version: 26.15.3([email protected])([email protected])
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'}
[email protected]3.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
[email protected]4.0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
[email protected]3.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
[email protected]4.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([email protected]):
electron@44.7.0([email protected]):
dependencies:
'@electron-internal/extract-zip': 1.0.5
'@electron/get': 5.1.0([email protected])
@@ -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:
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
dependencies:
semver: 7.8.5
semver: 7.7.4
[email protected]:
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
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]3.0:
[email protected]4.0:
dependencies:
playwright-core: 1.63.0
playwright-core: 1.64.0
[email protected]:
dependencies:
+21
View File
@@ -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 };
+4
View File
@@ -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;
+35
View File
@@ -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 };
+44 -39
View File
@@ -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", () => {
+3
View File
@@ -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.
+10 -1
View File
@@ -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 };
+13 -4
View File
@@ -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));
+109
View File
@@ -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 };
+328 -1
View File
@@ -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");
});
+94
View File
@@ -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);
});
+8 -8
View File
@@ -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"
}
}
+7
View File
@@ -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/`(它要读固件源码来决定
+26 -1
View File
@@ -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 "));
+43
View File
@@ -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 "));
+92
View File
@@ -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");
@@ -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);
+119
View File
@@ -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");
+26
View File
@@ -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<string, BenchPort>();
private readonly settingsPort = new BenchPort();
machinePort(): AgentPort {
return this.settingsPort;
}
portFor(rt: RuntimeView): AgentPort {
let port = this.feeds.get(rt.id);
+7 -76
View File
@@ -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);
+133
View File
@@ -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");
+7 -2
View File
@@ -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; }
+54
View File
@@ -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);
}
+3 -1
View File
@@ -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`);
@@ -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);
+83
View File
@@ -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");
+46
View File
@@ -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");
Loaded 100 of 1071 files, more files were not shown because too many files have changed in this diff. Show more