mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 01:51:33 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5207d6f89b |
No files matched your search
+1
-2
@@ -10,7 +10,6 @@
|
||||
|
||||
# User-facing guides and references.
|
||||
/docs/ @SivanCola @esengine
|
||||
/docs/THEME_AUTHOR_GUIDE.md @esengine @SivanCola
|
||||
/sdk/ @SivanCola @esengine
|
||||
/README.md @SivanCola @esengine
|
||||
/README.zh-CN.md @SivanCola @esengine
|
||||
@@ -23,6 +22,7 @@
|
||||
/docs/*_CONTRACT.md @esengine @SivanCola
|
||||
/docs/TOOL_APPROVAL_MODES.md @esengine @SivanCola
|
||||
/docs/CAPABILITY_DIAGNOSTICS.md @esengine @SivanCola
|
||||
/docs/CODE_SIGNING_POLICY.md @esengine @SivanCola
|
||||
/docs/CHECKPOINTS.md @esengine @SivanCola
|
||||
/docs/EXTENSION_*.md @esengine @SivanCola
|
||||
/docs/SUBAGENT_*.md @esengine @SivanCola
|
||||
@@ -37,7 +37,6 @@
|
||||
/.github/workflows/studio-certum-signing-smoke.yml @esengine @SivanCola
|
||||
/.github/actions/setup-certum/ @esengine @SivanCola
|
||||
/cmd/signpath-contract/ @esengine @SivanCola
|
||||
/scripts/install-smoke.ps1 @esengine @SivanCola
|
||||
/scripts/sign-certum.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-authenticode.ps1 @esengine @SivanCola
|
||||
/scripts/verify-windows-package.ps1 @esengine @SivanCola
|
||||
|
||||
@@ -6,9 +6,3 @@ contact_links:
|
||||
- name: Questions & discussions
|
||||
url: https://github.com/esengine/DeepSeek-Reasonix/discussions
|
||||
about: For usage questions, ideas, and general discussion — please use Discussions instead of opening an issue.
|
||||
- name: Discord
|
||||
url: https://discord.gg/XF78rEME2D
|
||||
about: Bilingual community chat for setup help, workflow showcases, and feature ideas.
|
||||
- name: QQ group (DeepSeek-Reasonix官方群, 1093562660)
|
||||
url: https://qm.qq.com/q/i59b0z2R8s
|
||||
about: Chinese-language community chat for setup help and everyday questions.
|
||||
@@ -8,22 +8,6 @@ only, on `main-v2`.
|
||||
|
||||
-
|
||||
|
||||
## Cause
|
||||
|
||||
-
|
||||
|
||||
## Blast radius
|
||||
|
||||
-
|
||||
|
||||
## Neighbouring behaviours tested
|
||||
|
||||
-
|
||||
|
||||
## Why this layer
|
||||
|
||||
-
|
||||
|
||||
## Issues
|
||||
|
||||
<!--
|
||||
@@ -42,7 +26,3 @@ the source-to-target contract here: easing syntax, time units, callbacks,
|
||||
cancellation, reduced-motion behavior, and failure fallback. Verification must
|
||||
assert that the target API was actually called; a mock that silently skips it
|
||||
does not count.
|
||||
|
||||
## Disclosure
|
||||
|
||||
- [ ] AI assistance was used for this change (optional; for transparency only, it does not change how the change is reviewed)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
+10
-14
@@ -62,7 +62,7 @@ jobs:
|
||||
# both paths must trigger the sdk job.
|
||||
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
|
||||
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/)'; then scripts=true; fi
|
||||
fi
|
||||
fi
|
||||
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
|
||||
@@ -80,11 +80,7 @@ jobs:
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # 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/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/release-credits.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.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
|
||||
@@ -179,12 +175,12 @@ jobs:
|
||||
# budget above normal 7-9 minute runner variance; each package test binary
|
||||
# remains independently bounded by Go's per-package timeout below.
|
||||
#
|
||||
# That timeout is 12m, not 3m: across four consecutive runs of the same
|
||||
# That timeout is 8m, not 3m: across four consecutive runs of the same
|
||||
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
|
||||
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
|
||||
# variance alone, with four heavy packages sharing four vCPUs. The boot package
|
||||
# has since grown to 422s and then ran past 480s on two consecutive runs of the
|
||||
# same code, so 8m no longer leaves room. A hang still trips the job budget.
|
||||
# variance alone, with four heavy packages sharing four vCPUs. At 3m the
|
||||
# step failed on slow runners without a code change; a hang still trips
|
||||
# the 15 minute job budget above.
|
||||
#
|
||||
# Every root-module `go test` that runs on Windows passes -count=1. With
|
||||
# the result cache on, go test records each file and environment access
|
||||
@@ -195,11 +191,11 @@ jobs:
|
||||
# internal/base/testenv holds every such step to it.
|
||||
- name: test (Windows smoke)
|
||||
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 15
|
||||
env:
|
||||
REASONIX_RELEASE_CACHE_GUARD: "1"
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
run: go test -count=1 -p 4 -timeout=8m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
|
||||
|
||||
# The general Windows PR smoke list intentionally omits internal/contract/tool.
|
||||
# Keep the session-temp portability contract covered without widening the
|
||||
@@ -218,7 +214,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
|
||||
|
||||
# Full push suite still uses the same 12m package budget as Windows smoke:
|
||||
# Full push suite still uses the same 8m package budget as Windows smoke:
|
||||
# agent/boot/control already hit 180s package timeouts under runner load,
|
||||
# so 3m fails on slow machines without a code regression. It runs with
|
||||
# -count=1 for the reason given at the smoke step. A hang still dies here
|
||||
@@ -234,7 +230,7 @@ jobs:
|
||||
# Bound sandbox helper children in Windows tests so a failed OS-level
|
||||
# launch cannot pin the Actions step after Go's package timeout fires.
|
||||
WINDOWS_SANDBOX_WAIT_MS: "20000"
|
||||
run: go test -count=1 -p 4 -timeout=12m ./...
|
||||
run: go test -count=1 -p 4 -timeout=8m ./...
|
||||
|
||||
race:
|
||||
needs: changes
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
name: Deploy ops events relay
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: deploy-ops-events
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.ref == 'refs/heads/platform'
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: workers/ops-events/package-lock.json
|
||||
- name: Validate
|
||||
working-directory: workers/ops-events
|
||||
run: |
|
||||
npm ci
|
||||
npm run typecheck
|
||||
npm test
|
||||
- name: Deploy
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
run: npx wrangler deploy
|
||||
- name: Sync secrets
|
||||
working-directory: workers/ops-events
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
GITHUB_WEBHOOK_SECRET: ${{ secrets.OPS_GITHUB_WEBHOOK_SECRET }}
|
||||
OPS_LISTEN_TOKEN: ${{ secrets.OPS_LISTEN_TOKEN }}
|
||||
OPS_EMIT_TOKEN: ${{ secrets.OPS_EMIT_TOKEN }}
|
||||
run: |
|
||||
node -e 'process.stdout.write(JSON.stringify({GITHUB_WEBHOOK_SECRET:process.env.GITHUB_WEBHOOK_SECRET||null,OPS_LISTEN_TOKEN:process.env.OPS_LISTEN_TOKEN||null,OPS_EMIT_TOKEN:process.env.OPS_EMIT_TOKEN||null}))' | npx wrangler secret bulk
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
name: Sync in-app feedback
|
||||
|
||||
# Turns feedback submitted from Reasonix Studio into issues and reports issue
|
||||
# state (in progress, fixed in a tag, won't fix, duplicate) back to the worker,
|
||||
# and mirrors the reporter's replies onto their issue as bot comments.
|
||||
# Text from the public reaches the script only through the worker's JSON and
|
||||
# the GitHub API, never through a shell command line or an expression.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/10 * * * *'
|
||||
- cron: '5 3 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Print what would happen without creating issues or writing back
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
full:
|
||||
description: Re-read every open entry instead of only recently changed issues
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: feedback-sync
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
if: github.repository == 'esengine/DeepSeek-Reasonix'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check secret
|
||||
id: gate
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$FEEDBACK_ADMIN_TOKEN" ]; then
|
||||
echo "::notice::FEEDBACK_ADMIN_TOKEN is not set; feedback sync skipped"
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
# git tag --contains needs full history; the tag API would cost a request per entry.
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- if: steps.gate.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: '22'
|
||||
- name: Sync
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
FEEDBACK_ADMIN_TOKEN: ${{ secrets.FEEDBACK_ADMIN_TOKEN }}
|
||||
# crash.reasonix.io answers runner IPs with a managed challenge; workers.dev serves only the converter's admin paths.
|
||||
FEEDBACK_BASE_URL: https://reasonix-crash-report.reasonix.workers.dev
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }}
|
||||
FULL: ${{ github.event.inputs.full || 'false' }}
|
||||
SCHEDULE: ${{ github.event.schedule }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$DRY_RUN" = "true" ]; then args+=(--dry-run); fi
|
||||
# The daily schedule sweeps everything the change window could have missed.
|
||||
if [ "$FULL" = "true" ] || [ "$SCHEDULE" = "5 3 * * *" ]; then args+=(--full); fi
|
||||
node scripts/feedback-sync.mjs "${args[@]}"
|
||||
@@ -5,12 +5,10 @@ run-name: Release Studio ${{ inputs.tag || github.ref_name }}
|
||||
# (studio-v*), its own environment gate, and its own rollback catalog at
|
||||
# dl.reasonix.io/studio/versions.json.
|
||||
#
|
||||
# What it must never touch: the root dl.reasonix.io/versions.json, which is the
|
||||
# desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs. The highest stable tag
|
||||
# is the repository's GitHub latest and a candidate is a prerelease; the GitHub
|
||||
# release carries no root latest.json, because the 1.x updaters' last-resort
|
||||
# URL is releases/latest/download/latest.json and must keep answering 404.
|
||||
# What it must never touch: the repository-wide "latest" release (every Studio
|
||||
# release is a prerelease) and the root dl.reasonix.io/versions.json, which is
|
||||
# the desktop line's catalog. Studio artifacts land under the tag prefix, which
|
||||
# cannot collide because the tag name itself differs.
|
||||
#
|
||||
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
|
||||
# certificate in two stages: every PE file in the application tree, then the
|
||||
@@ -76,7 +74,8 @@ jobs:
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
# Semver's prerelease segment, group 4, read from the grammar rather
|
||||
# than the spelling.
|
||||
# than the spelling. Every Studio release carries GitHub's prerelease
|
||||
# flag, so the tag-level distinction needs a name of its own.
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
@@ -149,21 +148,10 @@ jobs:
|
||||
# vocabularies disagree (x64 / amd64) and a reviewer has to see both.
|
||||
# Absent means the runner's own, which is what Windows and Linux build.
|
||||
# artifact is the exact name publish downloads this leg's packages by.
|
||||
# goarch and unpacked belong to the Windows legs: the release
|
||||
# architecture the package is named for, and the directory
|
||||
# electron-builder leaves its bundle in (win-unpacked for x64 alone).
|
||||
include:
|
||||
- platform: windows/amd64
|
||||
runner: windows-latest
|
||||
goarch: amd64
|
||||
unpacked: win-unpacked
|
||||
artifact: studio-build-windows-amd64
|
||||
- platform: windows/arm64
|
||||
runner: windows-11-arm
|
||||
ebarch: arm64
|
||||
goarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
artifact: studio-build-windows-arm64
|
||||
- platform: darwin/amd64
|
||||
runner: macos-latest
|
||||
ebarch: x64
|
||||
@@ -278,7 +266,6 @@ jobs:
|
||||
# kernel on a remote machine, and those routes name a release by it.
|
||||
env:
|
||||
REASONIX_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
REASONIX_GOARCH: ${{ matrix.goarch }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The helper the Linux self-update runs under pkexec, staged where the
|
||||
@@ -301,14 +288,6 @@ jobs:
|
||||
if [ -n "${{ matrix.ebarch }}" ]; then arch="--${{ matrix.ebarch }}"; fi
|
||||
pnpm exec electron-builder --dir $arch -c.extraMetadata.version="${version#v}"
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a bundle that starts
|
||||
# proves nothing about its architecture: every PE image in it has to be
|
||||
# built for the architecture the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: runner.os == 'Windows'
|
||||
shell: bash
|
||||
run: node desktop/electron/packaging/pe.js "dist/${{ matrix.unpacked }}" "${{ matrix.goarch }}"
|
||||
|
||||
# Notarization happens to the bundle, so it sits between the two packaging
|
||||
# steps the way Authenticode does on Windows. Stapling here is what lets a
|
||||
# first launch work offline; a ticket Apple has but the bundle does not is
|
||||
@@ -365,8 +344,8 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: dist/win-unpacked
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -377,24 +356,17 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS != 'true'
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# The arm64 installer has to be packed with the BCJ filter: 7-Zip
|
||||
# applies an ARM64 filter to ARM64 executables by default, the NSIS
|
||||
# extractor cannot decode it, and the installer then exits 0 with
|
||||
# every executable missing.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.goarch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
arch="--${{ matrix.ebarch || 'x64' }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
@@ -410,36 +382,22 @@ jobs:
|
||||
# package check run between them on their own runners with no secrets. Both
|
||||
# signing jobs share one group with the Studio smoke test, so no two runs
|
||||
# drive the same SimplySign account at once.
|
||||
#
|
||||
# Each signing stage is a matrix over the Windows architectures with one tree
|
||||
# per job. max-parallel: 1 keeps the legs strictly serial, which the shared
|
||||
# certum-signing group needs: a group holds one running and one waiting job,
|
||||
# so two legs started together would have the waiting one cancelled by the
|
||||
# next arrival. A failed leg reruns alone; the others' results are kept.
|
||||
windows-sign-payload:
|
||||
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
|
||||
name: sign the Windows PE files (Certum)
|
||||
needs: [resolve, signing-contract, build]
|
||||
if: vars.STUDIO_SIGNING_ENABLED == 'true'
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
concurrency:
|
||||
group: certum-signing
|
||||
cancel-in-progress: false
|
||||
# One output per architecture, because matrix legs share a job's output map:
|
||||
# a leg writes only its own and an empty value never replaces another's.
|
||||
# Recorded here because no later job can write this job's outputs: the
|
||||
# installer signing job refuses a payload artifact that no longer hashes to it.
|
||||
outputs:
|
||||
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest-arm64: ${{ matrix.arch == 'arm64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest: ${{ steps.verify.outputs.digest }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -480,7 +438,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: signing/app
|
||||
|
||||
# Stage one: every PE file in the application tree that Microsoft has
|
||||
@@ -527,7 +485,6 @@ jobs:
|
||||
$ErrorActionPreference = 'Stop'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-PayloadDirectory signing/app `
|
||||
-Architecture ${{ matrix.arch }} `
|
||||
-DigestPath "$env:RUNNER_TEMP/payload-digest" `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
-ExpectedSubject $env:EXPECTED_SUBJECT
|
||||
@@ -535,7 +492,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
name: signed-studio-windows-payload
|
||||
path: signing/app
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -543,20 +500,10 @@ jobs:
|
||||
# No environment and no secrets: electron-builder and its dependencies run
|
||||
# here, on a runner that never held a signing session.
|
||||
windows-package:
|
||||
name: package Windows from the signed tree (${{ matrix.arch }})
|
||||
name: package Windows from the signed tree
|
||||
needs: [resolve, windows-sign-payload]
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
ebarch: x64
|
||||
unpacked: win-unpacked
|
||||
- arch: arm64
|
||||
ebarch: arm64
|
||||
unpacked: win-arm64-unpacked
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
@@ -589,8 +536,8 @@ jobs:
|
||||
# The signed payload is the whole bundle, so it is packaged as it came.
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
name: signed-studio-windows-payload
|
||||
path: dist/win-unpacked
|
||||
|
||||
# Both packages come from the signed tree, so the archive carries the
|
||||
# signed files too rather than a second unsigned copy. The zip is packed
|
||||
@@ -598,27 +545,24 @@ jobs:
|
||||
- name: Package Windows from the signed bundle
|
||||
working-directory: desktop/electron
|
||||
shell: bash
|
||||
env:
|
||||
# See the build job: the arm64 installer must be packed with BCJ.
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
version="${{ needs.resolve.outputs.version }}"
|
||||
rm -rf ../../zip-src ../../dist-zip
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
cp -r ../../dist/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: |
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
|
||||
dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
dist/ReasonixStudio-windows-amd64.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -627,21 +571,15 @@ jobs:
|
||||
# secrets, never in a job that holds the Certum credentials. Its outputs are
|
||||
# the hashes of the two files it checked, which the signing job requires.
|
||||
windows-verify-package:
|
||||
name: check the Windows packages against the signed tree (${{ matrix.arch }})
|
||||
name: check the Windows packages against the signed tree
|
||||
needs: [resolve, windows-sign-payload, windows-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.installer-sha256 || '' }}
|
||||
archive-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
archive-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.check.outputs.archive-sha256 || '' }}
|
||||
installer-sha256: ${{ steps.check.outputs.installer-sha256 }}
|
||||
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -650,12 +588,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
name: signed-studio-windows-payload
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: dist
|
||||
|
||||
# 7-Zip is the runner image's, not the tag's.
|
||||
@@ -663,26 +601,20 @@ jobs:
|
||||
id: check
|
||||
shell: pwsh
|
||||
env:
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
|
||||
run: |
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
|
||||
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT
|
||||
|
||||
windows-sign-installer:
|
||||
name: sign the Windows installer (Certum, ${{ matrix.arch }})
|
||||
name: sign the Windows installer (Certum)
|
||||
needs: [resolve, windows-package, windows-verify-package]
|
||||
runs-on: windows-2022
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
arch: [amd64, arm64]
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -691,8 +623,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
# The signed installer's hash, which publish requires of what it downloads.
|
||||
outputs:
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
|
||||
steps:
|
||||
# Only the workflow's own commit is checked out: these jobs run the signer
|
||||
# and its checks, never anything the release tag builds.
|
||||
@@ -733,7 +664,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
name: packaged-studio-windows
|
||||
path: dist
|
||||
|
||||
# Only the two files windows-verify-package checked, byte for byte. This
|
||||
@@ -742,13 +673,13 @@ jobs:
|
||||
- name: Require the checked packages
|
||||
shell: pwsh
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs[format('installer-sha256-{0}', matrix.arch)] }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', matrix.arch)] }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [ordered]@{
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}.zip' = $env:ARCHIVE_SHA256
|
||||
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
|
||||
}
|
||||
$present = @(Get-ChildItem -LiteralPath dist -Recurse -Force | ForEach-Object { $_.Name })
|
||||
if (Compare-Object -CaseSensitive @($expected.Keys) $present) { throw "dist holds $($present -join ', ')" }
|
||||
@@ -770,7 +701,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
|
||||
# The session signs for any process on the runner while it is up.
|
||||
- name: Disconnect from Certum
|
||||
@@ -786,7 +717,7 @@ jobs:
|
||||
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
|
||||
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-FilePath $installer `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
@@ -796,60 +727,11 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
name: studio-windows-signed
|
||||
path: dist/ReasonixStudio-*
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
|
||||
# Installs each Windows installer where it is meant to run (the same script
|
||||
# the Studio workflow runs on pull requests, against the unsigned installer).
|
||||
# The package check unpacks with the runner's modern 7-Zip, which reads
|
||||
# archives the NSIS extractor cannot, so only running the installer shows that
|
||||
# it installs every executable. No environment and no secrets.
|
||||
windows-install-smoke:
|
||||
name: install the Windows package (${{ matrix.arch }})
|
||||
needs: [resolve, build, windows-sign-installer]
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
(needs.windows-sign-installer.result == 'success' ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-installer.result == 'skipped')) }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: windows-latest
|
||||
- arch: arm64
|
||||
runner: windows-11-arm
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
- name: Install, check and uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
# The kernel a Studio installs on another machine. This line publishes it
|
||||
# under its own tag, because the install routes name a release by the version
|
||||
# the local build reports — and a line that ships no archive leaves every one
|
||||
@@ -875,18 +757,14 @@ jobs:
|
||||
- name: Cross-compile and archive
|
||||
env:
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
built="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
pkg=reasonix/internal/tools/productdocs
|
||||
for target in darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 windows/amd64 windows/arm64; do
|
||||
os="${target%/*}"; arch="${target#*/}"; ext=""
|
||||
[ "$os" = windows ] && ext=".exe"
|
||||
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build -trimpath \
|
||||
-ldflags "-s -w -X main.version=$VERSION -X main.gitCommit=${SHA:0:12} -X main.buildTimeUTC=$built -X $pkg.linkedVersion=$VERSION -X $pkg.linkedRevision=$SHA" \
|
||||
-o "reasonix$ext" ./cmd/reasonix
|
||||
-ldflags "-s -w -X main.version=$VERSION" -o "reasonix$ext" ./cmd/reasonix
|
||||
if [ "$os" = windows ]; then
|
||||
zip -qj "dist/reasonix-$os-$arch.zip" "reasonix$ext"
|
||||
else
|
||||
@@ -920,13 +798,12 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: sign, publish and mirror
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
|
||||
# The Windows signing jobs may be skipped only while the switch is off; with
|
||||
# it on, any of them failing, cancelled or absent blocks the release.
|
||||
if: >-
|
||||
${{ !cancelled() && needs.resolve.result == 'success' && needs.build.result == 'success' &&
|
||||
needs.cli.result == 'success' && needs.windows-install-smoke.result == 'success' &&
|
||||
((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.cli.result == 'success' && ((needs.windows-sign-payload.result == 'success' &&
|
||||
needs.windows-package.result == 'success' && needs.windows-verify-package.result == 'success' &&
|
||||
needs.windows-sign-installer.result == 'success') ||
|
||||
(vars.STUDIO_SIGNING_ENABLED != 'true' && needs.windows-sign-payload.result == 'skipped' &&
|
||||
@@ -988,63 +865,43 @@ jobs:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
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
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
name: studio-windows-signed
|
||||
path: staging/studio-windows
|
||||
|
||||
# Any job can upload an artifact, so the signed Windows packages are
|
||||
# taken only if they are the bytes the signing jobs recorded: each
|
||||
# installer as signed, each zip as windows-verify-package checked it.
|
||||
# taken only if they are the bytes the signing jobs recorded: the
|
||||
# installer as signed, the zip as windows-verify-package checked it.
|
||||
- name: Require the recorded Windows packages
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
env:
|
||||
INSTALLER_SHA256_AMD64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'amd64')] }}
|
||||
ARCHIVE_SHA256_AMD64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'amd64')] }}
|
||||
INSTALLER_SHA256_ARM64: ${{ needs.windows-sign-installer.outputs[format('installer-sha256-{0}', 'arm64')] }}
|
||||
ARCHIVE_SHA256_ARM64: ${{ needs.windows-verify-package.outputs[format('archive-sha256-{0}', 'arm64')] }}
|
||||
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
run: |
|
||||
check() {
|
||||
local file="$1/$2" want="$3" got
|
||||
local file="staging/studio-windows/$1" want="$2" got
|
||||
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
|
||||
return 1
|
||||
fi
|
||||
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
for arch in amd64 arm64; do
|
||||
dir="staging/studio-windows-$arch"
|
||||
files="$(cd "$dir" && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-$arch-installer.exe ReasonixStudio-windows-$arch.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed-$arch holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
upper="${arch^^}"
|
||||
installer="INSTALLER_SHA256_$upper"
|
||||
archive="ARCHIVE_SHA256_$upper"
|
||||
check "$dir" "ReasonixStudio-windows-$arch-installer.exe" "${!installer}"
|
||||
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
|
||||
done
|
||||
files="$(cd staging/studio-windows && find . -type f | sed 's|^\./||' | LC_ALL=C sort | tr '\n' ' ')"
|
||||
if [ "$files" != "ReasonixStudio-windows-amd64-installer.exe ReasonixStudio-windows-amd64.zip " ]; then
|
||||
echo "::error title=studio-signing.package-set-mismatch::studio-windows-signed holds: $files"
|
||||
exit 1
|
||||
fi
|
||||
check ReasonixStudio-windows-amd64-installer.exe "$INSTALLER_SHA256"
|
||||
check ReasonixStudio-windows-amd64.zip "$ARCHIVE_SHA256"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
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
|
||||
if: needs.windows-sign-installer.result != 'success'
|
||||
with:
|
||||
name: studio-build-windows-arm64
|
||||
path: staging/studio-windows-arm64
|
||||
path: staging/studio-windows
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -1067,13 +924,13 @@ jobs:
|
||||
done < <(find staging/studio-* -type f -name 'ReasonixStudio-*' -print0
|
||||
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
|
||||
# means an artifact was lost between jobs, and a partial release is
|
||||
# worse than a failed one.
|
||||
# Two Windows artifacts (installer, portable zip), two per macOS
|
||||
# architecture (dmg, zip) and the Linux .deb. 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 7 ]; then
|
||||
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -1084,11 +941,7 @@ jobs:
|
||||
# Each macOS architecture by name, not darwin-*: a glob passes on
|
||||
# whichever one shipped, and the missing one is a platform left on
|
||||
# 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' \
|
||||
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
|
||||
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
|
||||
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
|
||||
find dist -type f -name "$required" | grep -q . || {
|
||||
echo "::error::no $required in the release; its packager did not run"
|
||||
@@ -1137,14 +990,12 @@ jobs:
|
||||
if [ "$HAS_R2" = "true" ]; then delta_dir=../delta; fi
|
||||
go run ./cmd/studio-manifest ../dist "${{ needs.resolve.outputs.version }}" "${{ needs.resolve.outputs.tag }}" $delta_dir
|
||||
|
||||
# The manifest stays out of the GitHub release (it is mirrored to R2
|
||||
# under <tag>/ below), and a stable release takes the repository's latest
|
||||
# only when it is the highest stable studio tag.
|
||||
# Always a prerelease: this line is not the public product line, and
|
||||
# GitHub never promotes a prerelease to the repository-wide "latest".
|
||||
- name: Publish GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CANDIDATE: ${{ needs.resolve.outputs.candidate }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
SIGNED_WINDOWS: ${{ needs.windows-sign-installer.result == 'success' }}
|
||||
run: |
|
||||
@@ -1185,20 +1036,15 @@ jobs:
|
||||
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
|
||||
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
|
||||
} >> "$notes"
|
||||
assets=()
|
||||
for f in dist/*; do
|
||||
[ "$(basename "$f")" = latest.json ] || assets+=("$f")
|
||||
done
|
||||
if gh release view "$TAG" >/dev/null 2>&1; then
|
||||
gh release edit "$TAG" --notes-file "$notes"
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
gh release upload "$TAG" dist/* --clobber
|
||||
else
|
||||
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
|
||||
mapfile -t flags <<<"$decided"
|
||||
gh release create "$TAG" "${assets[@]}" \
|
||||
gh release create "$TAG" dist/* \
|
||||
--title "Reasonix Studio $VERSION" \
|
||||
--notes-file "$notes" \
|
||||
"${flags[@]}"
|
||||
--prerelease \
|
||||
--latest=false
|
||||
fi
|
||||
|
||||
- name: Mirror to R2 and update the Studio catalog
|
||||
@@ -1242,22 +1088,6 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The notes object goes up before the catalog entry that names it, so
|
||||
# no client reads a pointer to an object that is not there yet. Notes
|
||||
# are optional: a failure here publishes the version without a pointer
|
||||
# rather than keeping the new version out of the catalog.
|
||||
notes_key="studio/notes/${VERSION#v}.md"
|
||||
notes_arg=()
|
||||
if aws s3 cp "$RUNNER_TEMP/version-notes.md" "s3://${R2_BUCKET}/${notes_key}" \
|
||||
--endpoint-url "$endpoint" \
|
||||
--content-type "text/markdown; charset=utf-8" \
|
||||
--cache-control "public, max-age=31536000, immutable" &&
|
||||
aws s3api head-object --bucket "$R2_BUCKET" --key "$notes_key" --endpoint-url "$endpoint" >/dev/null; then
|
||||
notes_arg=(20 "https://dl.reasonix.io/${notes_key}")
|
||||
else
|
||||
echo "::warning::release notes for ${VERSION} were not uploaded to the mirror; the catalog entry is published without notes"
|
||||
fi
|
||||
|
||||
# Studio's own catalog. Writing the ROOT versions.json here would hand
|
||||
# desktop users Studio releases, so the prefix is not optional.
|
||||
current=/tmp/studio-versions-current.json
|
||||
@@ -1269,7 +1099,7 @@ jobs:
|
||||
echo "no existing Studio catalog; starting a new one"
|
||||
fi
|
||||
bash scripts/update-versions-index.sh "$source" "$VERSION" "$TAG" studio \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$next"
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$next"
|
||||
jq -e --arg v "$VERSION" '.versions | map(select(.version == $v)) | length == 1' "$next" >/dev/null
|
||||
aws s3 cp "$next" "s3://${R2_BUCKET}/studio/versions.json" \
|
||||
--endpoint-url "$endpoint" \
|
||||
@@ -1277,125 +1107,50 @@ jobs:
|
||||
--cache-control "public, max-age=300, stale-if-error=86400"
|
||||
echo "Studio catalog -> $VERSION"
|
||||
|
||||
# A CLI channel that moves while 1.x still publishes would have two lines
|
||||
# writing it, so every CLI job waits for this check. It runs no repository code.
|
||||
cli-gate:
|
||||
name: require the 1.x line to be frozen
|
||||
needs: resolve
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions: {}
|
||||
steps:
|
||||
- name: Require CLI_PUBLISH_FROZEN
|
||||
env:
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
run: |
|
||||
if [ "$CLI_PUBLISH_FROZEN" != true ]; then
|
||||
echo "::error::STUDIO_PUBLISHES_CLI is on but CLI_PUBLISH_FROZEN is not: freeze the 1.x line first, or two lines would write the CLI channels"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The v<version> tag the update pointer needs is created here, by the release
|
||||
# identity the 1.x line also uses, because only that identity may create v*
|
||||
# tags. This job checks out nothing and runs no repository code; the one step
|
||||
# that reads RELEASE_TAG_TOKEN is written inline and only runs on the studio
|
||||
# tag or branch, through the studio-release environment's ref policy.
|
||||
cli-tag:
|
||||
name: create the CLI tag
|
||||
needs: [resolve, publish, cli-gate]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
environment: studio-release
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Create v<version> on the studio commit
|
||||
env:
|
||||
TAG_TOKEN: ${{ secrets.RELEASE_TAG_TOKEN }}
|
||||
RELEASE_TAG_ACTOR: ${{ vars.RELEASE_TAG_ACTOR }}
|
||||
READ_TOKEN: ${{ github.token }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo=esengine/DeepSeek-Reasonix
|
||||
stable_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
preview_re='^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-preview\.(0|[1-9][0-9]*)$'
|
||||
tag="v${CLI_VERSION#v}"
|
||||
if [[ ! "$tag" =~ $stable_re && ! "$tag" =~ $preview_re ]]; then
|
||||
echo "$tag publishes no CLI pointer; no tag is created"
|
||||
exit 0
|
||||
fi
|
||||
[ "$CLI_PUBLISH_FROZEN" = true ] || { echo "::error::CLI_PUBLISH_FROZEN must be true"; exit 1; }
|
||||
[ "$GITHUB_REPOSITORY" = "$repo" ] || { echo "::error::the release tag identity is only used in $repo"; exit 1; }
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/studio-v* | refs/heads/studio) ;;
|
||||
*) echo "::error::the release tag identity is only used on a studio tag or the studio branch, not $GITHUB_REF"; exit 1 ;;
|
||||
esac
|
||||
[[ "$APPROVED_SHA" =~ ^[0-9a-f]{40}$ ]] || { echo "::error::approved SHA is not a full commit SHA"; exit 1; }
|
||||
[[ "$STUDIO_TAG" == "studio-$CLI_VERSION" ]] || { echo "::error::studio tag does not match the version"; exit 1; }
|
||||
[ -n "$TAG_TOKEN" ] || { echo "::error::RELEASE_TAG_TOKEN is not configured; the default Actions token cannot create v* tags"; exit 1; }
|
||||
[[ "$RELEASE_TAG_ACTOR" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] || { echo "::error::configure RELEASE_TAG_ACTOR with the tag publisher's login"; exit 1; }
|
||||
read_api() { GH_TOKEN="$READ_TOKEN" gh api "$@"; }
|
||||
write_api() { GH_TOKEN="$TAG_TOKEN" gh api "$@"; }
|
||||
actor="$(write_api user)"
|
||||
jq -e --arg login "$RELEASE_TAG_ACTOR" '.type == "User" and (.login | ascii_downcase) == ($login | ascii_downcase)' <<<"$actor" >/dev/null ||
|
||||
{ echo "::error::the release tag credential does not identify RELEASE_TAG_ACTOR"; exit 1; }
|
||||
[ "$(write_api "repos/$repo" --jq .permissions.push)" = true ] ||
|
||||
{ echo "::error::the release tag identity cannot push to $repo"; exit 1; }
|
||||
[ "$(read_api "repos/$repo/commits/$STUDIO_TAG" --jq .sha)" = "$APPROVED_SHA" ] ||
|
||||
{ echo "::error::$STUDIO_TAG does not resolve to $APPROVED_SHA"; exit 1; }
|
||||
case "$(read_api "repos/$repo/compare/studio...$APPROVED_SHA" --jq .status)" in
|
||||
behind | identical) ;;
|
||||
*) echo "::error::$APPROVED_SHA is not on studio history"; exit 1 ;;
|
||||
esac
|
||||
# Prints the commit the tag points at; status 1 only when the tag does not exist.
|
||||
tag_commit() {
|
||||
local ref kind sha
|
||||
ref="$(read_api "repos/$repo/git/ref/tags/$tag" --jq '.object.type + " " + .object.sha' 2>"$RUNNER_TEMP/tag.error")" || {
|
||||
grep -Eiq '404|Not Found' "$RUNNER_TEMP/tag.error" && return 1
|
||||
cat "$RUNNER_TEMP/tag.error" >&2
|
||||
exit 1
|
||||
}
|
||||
read -r kind sha <<<"$ref"
|
||||
[ "$kind" != tag ] || sha="$(read_api "repos/$repo/git/tags/$sha" --jq .object.sha)"
|
||||
echo "$sha"
|
||||
}
|
||||
if existing="$(tag_commit)"; then
|
||||
[ "$existing" = "$APPROVED_SHA" ] || { echo "::error::$tag already exists on $existing, not $APPROVED_SHA"; exit 1; }
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
if ! write_api "repos/$repo/git/refs" -f "ref=refs/tags/$tag" -f "sha=$APPROVED_SHA" >/dev/null 2>"$RUNNER_TEMP/create.error"; then
|
||||
# A concurrent run may have created the same tag in the meantime.
|
||||
if existing="$(tag_commit)" && [ "$existing" = "$APPROVED_SHA" ]; then
|
||||
echo "$tag already points at $APPROVED_SHA"
|
||||
exit 0
|
||||
fi
|
||||
cat "$RUNNER_TEMP/create.error" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "created $tag at $APPROVED_SHA"
|
||||
|
||||
# The CLI's install channels, npm's `reasonix` and the Homebrew tap. The 1.x
|
||||
# line owns them until this one takes the CLI over; the repository variable
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once cli-pointer has
|
||||
# created the CLI release, because the cask points at its archives. A candidate
|
||||
# goes to npm's `next` tag and leaves the tap alone: brew has no prerelease
|
||||
# channel, so a candidate cask would become the default install.
|
||||
# STUDIO_PUBLISHES_CLI=true hands them here. They run once the release
|
||||
# exists, because the cask points at its archives. A candidate goes to npm's
|
||||
# `next` tag and leaves the tap alone: brew has no prerelease channel, so a
|
||||
# candidate cask would become the default install.
|
||||
cli-channels:
|
||||
name: publish the CLI to npm and Homebrew
|
||||
needs: [resolve, publish, cli-gate, cli-tag, cli-pointer]
|
||||
needs: [resolve, publish]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
# Owner approval before anything reaches npm or the tap: the switch alone
|
||||
# would otherwise publish on every studio tag.
|
||||
environment: studio-cli-channels
|
||||
# id-token lets npm publish --provenance attach a Sigstore attestation; the
|
||||
# registry token still authenticates the publish itself.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
# A job naming a missing environment gets an unprotected one, so the
|
||||
# gate is verified rather than assumed; the tokens below exist only in it.
|
||||
- name: Require the approval gate on studio-cli-channels
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -uo pipefail
|
||||
resp="$(gh api -i "repos/$REPO/environments/studio-cli-channels" 2>&1)"
|
||||
status="$(printf '%s\n' "$resp" | sed -n '1s/^HTTP\/[0-9.]* \([0-9]*\).*/\1/p')"
|
||||
case "$status" in
|
||||
200) ;;
|
||||
404)
|
||||
echo "::error::environment studio-cli-channels does not exist"
|
||||
exit 1 ;;
|
||||
*)
|
||||
echo "::error::reading environment studio-cli-channels as ${GITHUB_ACTOR} failed with HTTP ${status:-unknown}"
|
||||
exit 1 ;;
|
||||
esac
|
||||
reviewers="$(printf '%s\n' "$resp" | sed '1,/^\r\{0,1\}$/d' | jq '[.protection_rules[] | select(.type == "required_reviewers") | .reviewers | length] | add // 0')"
|
||||
if ! [[ "$reviewers" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "::error::environment studio-cli-channels has no required reviewers (or its protection rules were unreadable: '${reviewers}'); refusing to publish"
|
||||
exit 1
|
||||
fi
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
@@ -1409,7 +1164,7 @@ jobs:
|
||||
registry-url: https://registry.npmjs.org
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
NODE_AUTH_TOKEN: ${{ secrets.STUDIO_NPM_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
run: node npm/build.mjs "$VERSION" --publish
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@@ -1420,24 +1175,12 @@ jobs:
|
||||
- name: Update the Homebrew cask
|
||||
if: needs.resolve.outputs.candidate != 'true'
|
||||
env:
|
||||
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
TAP_TOKEN: ${{ secrets.STUDIO_HOMEBREW_TAP_TOKEN }}
|
||||
VERSION: ${{ needs.resolve.outputs.version }}
|
||||
TAG: ${{ needs.resolve.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 "https://x-access-token:${TAP_TOKEN}@github.com/esengine/homebrew-reasonix.git" tap
|
||||
decision="$(node scripts/brew-cask-guard.mjs tap/Casks/reasonix.rb "${VERSION#v}")"
|
||||
case "$decision" in
|
||||
write) ;;
|
||||
skip)
|
||||
echo "the tap's cask is newer than ${VERSION#v}; leaving it"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "::error::unexpected cask guard decision: '$decision'"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
bash scripts/render-brew-cask.sh "${VERSION#v}" "$TAG" cli-archives/SHA256SUMS > tap/Casks/reasonix.rb
|
||||
cd tap
|
||||
git add Casks/reasonix.rb
|
||||
@@ -1447,47 +1190,3 @@ jobs:
|
||||
fi
|
||||
git -c user.name=reasonix -c [email protected] commit -m "reasonix ${VERSION#v}"
|
||||
git push origin HEAD:main
|
||||
|
||||
# The 1.x updater accepts only a release tagged vMAJOR.MINOR.PATCH whose assets
|
||||
# sit under that tag, so the archives go to a v<version> release. Only a
|
||||
# -preview.N version moves preview; other prereleases publish nothing.
|
||||
cli-pointer:
|
||||
name: publish the CLI update pointer
|
||||
needs: [resolve, publish, cli-gate, cli-tag]
|
||||
if: vars.STUDIO_PUBLISHES_CLI == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: studio-cli-pointer-${{ contains(needs.resolve.outputs.version, '-') && 'preview' || 'stable' }}
|
||||
cancel-in-progress: false
|
||||
permissions:
|
||||
contents: write # create the CLI release; cli-tag has already made its tag
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
ref: ${{ needs.resolve.outputs.sha }}
|
||||
persist-credentials: false
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: cli-archives
|
||||
path: cli-archives
|
||||
- name: Publish the release, its record and the pointer
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CLI_VERSION: ${{ needs.resolve.outputs.version }}
|
||||
CLI_PUBLISH_FROZEN: ${{ vars.CLI_PUBLISH_FROZEN }}
|
||||
APPROVED_SHA: ${{ needs.resolve.outputs.sha }}
|
||||
STUDIO_TAG: ${{ needs.resolve.outputs.tag }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
ARCHIVES_DIR: cli-archives
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
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; the CLI pointer was not written"
|
||||
exit 1
|
||||
fi
|
||||
bash scripts/publish-cli-pointer.sh
|
||||
+12
-203
@@ -81,64 +81,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Real-Chromium guards that run without a kernel. Only the ones that passed
|
||||
# three consecutive local runs are wired; the rest stay manual (see
|
||||
# desktop/frontend-next/perf/README.md). Skipped unless the SPA changed.
|
||||
browser-guards:
|
||||
name: browser guards
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop/frontend-next
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: SPA changed?
|
||||
id: spa
|
||||
working-directory: .
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] &&
|
||||
git diff --quiet "origin/${{ github.base_ref }}...HEAD" -- desktop/frontend-next; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Install
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Build the perf harness and install Chromium
|
||||
run: |
|
||||
pnpm exec vite build --config vite.perf.config.ts
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.spa.outputs.run == 'true'
|
||||
name: Guards
|
||||
run: |
|
||||
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/contrast.mjs
|
||||
node perf/wheel.mjs
|
||||
|
||||
desktop:
|
||||
name: desktop (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -179,45 +121,6 @@ jobs:
|
||||
CGO_ENABLED=0 GOOS=$os GOARCH=amd64 go build -o /dev/null ./cmd/reasonix-studio-host
|
||||
done
|
||||
|
||||
# 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.
|
||||
scope:
|
||||
name: package matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.pick.outputs.matrix }}
|
||||
arm64: ${{ steps.pick.outputs.arm64 }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- id: pick
|
||||
env:
|
||||
EVENT: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
arm64=true
|
||||
if [ "$EVENT" = pull_request ]; then
|
||||
arm64=false
|
||||
if git diff --name-only "$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
|
||||
fi
|
||||
legs='[
|
||||
{"os":"macos-latest","arch":"arm64","platform":"darwin"},
|
||||
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
|
||||
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
|
||||
]'
|
||||
if [ "$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"
|
||||
|
||||
# The packaging contract, on real artifacts rather than on the config that
|
||||
# describes them. Unsigned on purpose: signing and notarization are a
|
||||
# distribution concern and belong to the release workflow, and treating their
|
||||
@@ -225,12 +128,20 @@ jobs:
|
||||
# cross-compile while only Windows had ever produced a package.
|
||||
package:
|
||||
name: package (${{ matrix.os }})
|
||||
needs: scope
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
arch: arm64
|
||||
platform: darwin
|
||||
- os: ubuntu-22.04
|
||||
arch: amd64
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
arch: amd64
|
||||
platform: windows
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -263,13 +174,8 @@ jobs:
|
||||
working-directory: desktop/frontend-next
|
||||
run: pnpm install --frozen-lockfile && pnpm build
|
||||
|
||||
# The Windows legs name their architecture instead of taking the
|
||||
# toolchain's, so a runner whose Go or Node is the other architecture
|
||||
# fails here rather than shipping a mislabelled package.
|
||||
- name: Build the kernel the shell speaks to
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
REASONIX_GOARCH: ${{ matrix.platform == 'windows' && matrix.arch || '' }}
|
||||
run: pnpm install --frozen-lockfile && pnpm run build:host
|
||||
|
||||
# The .deb's absolute-path payload. A no-op off Linux, so it is not
|
||||
@@ -285,8 +191,7 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
run: pnpm run build:dist
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -302,78 +207,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# An emulated x64 program runs on ARM64 Windows, so a package that merely
|
||||
# starts proves nothing about its architecture: every PE image in the
|
||||
# bundle has to be built for the one the package is named for.
|
||||
- name: Every PE image is built for the package's architecture
|
||||
if: matrix.platform == 'windows'
|
||||
run: |
|
||||
dir=$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)
|
||||
test -n "$dir"
|
||||
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
|
||||
|
||||
# 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
|
||||
# elevate.exe, every PE image's architecture) is exercised on pull
|
||||
# requests and not first by a release.
|
||||
- name: The packages read back against the bundle they were made from
|
||||
if: matrix.platform == 'windows'
|
||||
shell: bash
|
||||
env:
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: |
|
||||
rm -rf payload nsis-src packed zip-src
|
||||
cp -r "$(find dist -maxdepth 1 -type d -name 'win*-unpacked' | head -1)" payload
|
||||
rm -f payload/resources/elevate.exe
|
||||
# The NSIS run writes elevate.exe into the tree it packs, so each
|
||||
# packager gets a copy and payload stays what was signed.
|
||||
cp -r payload zip-src
|
||||
cp -r payload nsis-src
|
||||
cd desktop/electron
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed-zip
|
||||
pnpm exec electron-builder --prepackaged ../../nsis-src --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version=0.0.0-ci -c.directories.output=../../packed
|
||||
mv ../../packed-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../packed/
|
||||
ls -la ../../packed
|
||||
|
||||
- name: verify-windows-package.ps1 accepts them
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. ./scripts/windows-signing-lib.ps1
|
||||
$digest = Get-ManifestDigest -Manifest @(Get-TreeManifest -Root payload)
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $digest `
|
||||
-InstallerPath "packed/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "packed/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath "$env:RUNNER_TEMP/verify-output.txt" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
Get-Content "$env:RUNNER_TEMP/verify-output.txt"
|
||||
|
||||
# The release's own install smoke, against the unsigned installer: the
|
||||
# package is installed where it is meant to run, its tree checked, and
|
||||
# removed.
|
||||
- name: The package installs, runs and uninstalls
|
||||
if: matrix.platform == 'windows'
|
||||
shell: pwsh
|
||||
run: |
|
||||
./scripts/install-smoke.ps1 `
|
||||
-Installer "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
- 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
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: dist/ReasonixStudio-windows-arm64-installer.exe
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The update channel is the signed manifest. A second answer to which
|
||||
# version is current is not a convenience. The .blockmap is the same
|
||||
# channel's differential index and carries the release prefix, so it also
|
||||
@@ -468,27 +301,3 @@ jobs:
|
||||
echo "::error::the manifest has no entry for ${{ matrix.platform }}-${{ matrix.arch }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# The arm64 installer carries one application and no other. Run anywhere but
|
||||
# ARM64 Windows it has to say so and stop, instead of exiting 0 with nothing
|
||||
# installed.
|
||||
arm64-installer-refuses-x64:
|
||||
name: arm64 installer refuses an x64 machine
|
||||
needs: [scope, package]
|
||||
if: needs.scope.outputs.arm64 == 'true'
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: studio-arm64-installer-ci
|
||||
path: installer
|
||||
|
||||
- name: Run it silently
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$target = Join-Path $env:RUNNER_TEMP 'rx-refused'
|
||||
$run = Start-Process -FilePath installer/ReasonixStudio-windows-arm64-installer.exe -ArgumentList '/S', "/D=$target" -Wait -PassThru
|
||||
Write-Host "exit code $($run.ExitCode)"
|
||||
if ($run.ExitCode -eq 0) { throw 'the arm64 installer exited 0 on an x64 machine' }
|
||||
if (@(Get-ChildItem -Recurse -Force $target -ErrorAction SilentlyContinue).Count -ne 0) { throw 'the arm64 installer wrote into the target on an x64 machine' }
|
||||
@@ -15,7 +15,6 @@
|
||||
/runtime-resume
|
||||
/extension-protocol-gen
|
||||
/reasonix-plugin-example
|
||||
/mcp-line-counter-kit
|
||||
/reasonix-studio-host
|
||||
/signpath-contract
|
||||
/studio-delta
|
||||
|
||||
@@ -11,7 +11,6 @@ fingerprint_files:
|
||||
- .github/workflows/release-studio.yml
|
||||
- .github/workflows/studio-certum-signing-smoke.yml
|
||||
- .github/actions/setup-certum/action.yml
|
||||
- scripts/install-smoke.ps1
|
||||
- scripts/sign-certum.ps1
|
||||
- scripts/verify-windows-authenticode.ps1
|
||||
- scripts/verify-windows-package.ps1
|
||||
|
||||
@@ -181,28 +181,6 @@ porting to `studio`; do not open the same PR against both.
|
||||
5. Ensure `gofmt -l .` shows no changes
|
||||
6. Submit a pull request to that same branch
|
||||
|
||||
## Pull request policy
|
||||
|
||||
**Related changes.** If you have several related changes of one pattern, a
|
||||
tracking issue plus one pull request is easiest to review.
|
||||
|
||||
**Issue first for features.** A change that adds behaviour rather than fixing a
|
||||
defect should link an issue the maintainers have agreed to before the work
|
||||
starts. A fix for a reproducible defect may open directly, with the failing test
|
||||
in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
|
||||
|
||||
**Review tiers.**
|
||||
|
||||
- Changes under the `sensitive:` paths declared in `REASONIX.md` get a full
|
||||
review plus a security review, regardless of author.
|
||||
- Every PR carries the four sections of the PR template: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, Why this layer.
|
||||
- The new test must fail without the change.
|
||||
|
||||
**Transparency.** The PR template has an optional checkbox for disclosing AI
|
||||
assistance. It is for transparency only and does not change how a change is
|
||||
reviewed.
|
||||
|
||||
## Reporting issues
|
||||
|
||||
Open an issue on GitHub with:
|
||||
|
||||
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">Website</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,7 +31,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,16 +41,12 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
|
||||
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
|
||||
<h3 align="center">A coding agent you can leave running.</h3>
|
||||
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
|
||||
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Community · 加入社区** — bilingual Discord for setup help (`#help` / `#求助`), workflow showcases, and feature ideas. → **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ group **DeepSeek-Reasonix官方群** (`1093562660`) → **<https://qm.qq.com/q/i59b0z2R8s>** ([QR code](./docs/assets/qq-group.svg))
|
||||
|
||||
> Douyin: **做游戏的小鱼** (Douyin ID `22703872788`) · [QR code](./docs/assets/douyin.png)
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -63,7 +56,7 @@ Reasonix ships on two lines. See the [version roadmap announcement](https://gith
|
||||
|
||||
| Line | Branch | Status | Get it |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | Maintenance / stable | `npm i -g reasonix` · [desktop download](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **Want something stable?** Stay on 1.x. It keeps receiving bug fixes,
|
||||
@@ -106,8 +99,8 @@ Studio, and editor integrations all run the same local Reasonix engine.
|
||||
|
||||
Download the package for your platform from the latest
|
||||
[Studio release](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
|
||||
pre-releases):
|
||||
(tagged `studio-v2.*`, published as pre-releases while 2.x is in active
|
||||
development):
|
||||
|
||||
| Platform | Package | Architecture |
|
||||
| --- | --- | --- |
|
||||
@@ -272,9 +265,8 @@ coffee, not a contract — donations don't buy feature priority or change how
|
||||
issues get triaged.
|
||||
|
||||
- **International** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
- **国内** — 微信支付 / 支付宝(扫码)
|
||||
- **国内** — 微信支付(扫码)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="WeChat Pay QR code" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="Alipay QR code" width="180"/>
|
||||
</p>
|
||||
+4
-12
@@ -18,8 +18,6 @@
|
||||
<a href="https://esengine.github.io/DeepSeek-Reasonix/">官方网站</a>
|
||||
·
|
||||
<strong><a href="https://discord.gg/XF78rEME2D">Discord</a></strong>
|
||||
·
|
||||
<strong><a href="https://qm.qq.com/q/i59b0z2R8s">QQ</a></strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,7 +31,6 @@
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/graphs/contributors"><img src="https://img.shields.io/github/contributors/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=bc8cff&labelColor=161b22&logo=github&logoColor=white" alt="contributors"/></a>
|
||||
<a href="https://github.com/esengine/DeepSeek-Reasonix/discussions"><img src="https://img.shields.io/github/discussions/esengine/DeepSeek-Reasonix.svg?style=flat-square&color=58a6ff&labelColor=161b22&logo=github&logoColor=white" alt="Discussions"/></a>
|
||||
<a href="https://discord.gg/XF78rEME2D"><img src="https://img.shields.io/badge/discord-join-5865F2.svg?style=flat-square&labelColor=161b22&logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
<a href="https://qm.qq.com/q/i59b0z2R8s"><img src="https://img.shields.io/badge/QQ%20group-1093562660-12B7F5.svg?style=flat-square&labelColor=161b22&logo=tencentqq&logoColor=white" alt="QQ group 1093562660"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -44,16 +41,12 @@
|
||||
<br/>
|
||||
|
||||
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
|
||||
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
|
||||
<h3 align="center">可以一直开着跑的编码 Agent。</h3>
|
||||
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
|
||||
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **加入社区 · Community** — 双语 Discord,提供安装答疑(`#help` / `#求助`)、工作流展示与功能想法。→ **<https://discord.gg/XF78rEME2D>**
|
||||
>
|
||||
> QQ 群 **DeepSeek-Reasonix官方群**(群号 `1093562660`)→ **<https://qm.qq.com/q/i59b0z2R8s>**([二维码](./docs/assets/qq-group.svg))
|
||||
|
||||
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
|
||||
|
||||
## 版本
|
||||
|
||||
@@ -61,7 +54,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
|
||||
| 版本线 | 分支 | 状态 | 获取方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio`(当前分支) | 活跃开发,预发布 | [Studio 发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 1.x** | [`main-v2`](https://github.com/esengine/DeepSeek-Reasonix/tree/main-v2) | 维护 / 稳定 | `npm i -g reasonix` · [桌面端下载](https://reasonix.io/?download=desktop#start) |
|
||||
|
||||
- **想要稳定**:继续使用 1.x。它会持续收到 Bug 修复、Provider / API 兼容、
|
||||
@@ -95,7 +88,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
### Reasonix Studio(2.x)
|
||||
|
||||
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
|
||||
|
||||
| 平台 | 安装包 | 架构 |
|
||||
| --- | --- | --- |
|
||||
@@ -252,10 +245,9 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
|
||||
如果 Reasonix 帮你省了时间或 token,欢迎请杯咖啡。捐助不会换来 feature
|
||||
优先级,也不会影响 issue 的处理顺序——就是「谢谢」。
|
||||
|
||||
- **国内** — 微信支付 / 支付宝(扫下方二维码)
|
||||
- **国内** — 微信支付(扫下方二维码)
|
||||
- **海外** — PayPal: [paypal.me/yuhuahui](https://paypal.me/yuhuahui)
|
||||
|
||||
<p align="center">
|
||||
<img src=".github/sponsor/wechat-pay.jpg" alt="微信支付收款码" width="180"/>
|
||||
<img src=".github/sponsor/alipay.jpg" alt="支付宝收款码" width="180"/>
|
||||
</p>
|
||||
+3
-30
@@ -219,11 +219,9 @@ otherwise the block the model already has goes on reading as current.
|
||||
- `#<note>` in chat quick-adds an always-on instruction. The `remember` tool
|
||||
instead saves a fallible background fact (frontmatter file + `MEMORY.md`
|
||||
index). Fact `type` classifies content; independent `scope` controls whether it
|
||||
is project-only (the default) or explicitly global.
|
||||
- The saved-fact index stays out of the stable prefix and is reached through the
|
||||
`memory` tool; pinned bodies and global user/feedback bodies load into it, the
|
||||
latter as lower-priority compatibility guidance. The current turn receives a
|
||||
tail note.
|
||||
is project-only (the default) or explicitly global. The index loads into the
|
||||
stable prefix on the next session; global user/feedback bodies also load as
|
||||
lower-priority compatibility guidance. The current turn receives a tail note.
|
||||
|
||||
## Notes
|
||||
|
||||
@@ -322,31 +320,6 @@ Use `go test ./path/to/target/` to detect cycles **before** pushing. A `[setup f
|
||||
- **Keep the PR diff minimal.** Only the files relevant to the PR's purpose — no stray changes from other branches.
|
||||
- **Amend, don't add commits, for review feedback** — keeps the commit history clean.
|
||||
|
||||
## Change discipline
|
||||
|
||||
**Assess before editing.** For every change, humans and agents list callers,
|
||||
consumers of the same state, sibling features, reload/replay/restart paths,
|
||||
error and edge states, TUI/CLI and desktop equivalents, and wire mirrors.
|
||||
|
||||
A fix that breaks a neighbouring behaviour is a failed fix.
|
||||
|
||||
**Test the neighbourhood.** UI changes add or extend interaction tests for
|
||||
adjacent functions of the same component or flow (composer: submit, clear,
|
||||
queue, withdraw, restore, steer, failure, replay after reload) and run the whole
|
||||
affected suite.
|
||||
|
||||
Write tests first; they must fail without the change.
|
||||
|
||||
**Fix at the owning layer.** Use the existing unified pattern: no special-case
|
||||
flags, duplicated paths, or workarounds at the symptom site.
|
||||
|
||||
If the design is duplicated or wrong, make the smallest unifying change:
|
||||
one path instead of two, deleting what becomes dead. Use current language and
|
||||
framework idioms; do not add a new pattern where one exists.
|
||||
|
||||
**PR evidence.** Every PR description carries: Cause, Blast radius,
|
||||
Neighbouring behaviours tested, and Why this layer.
|
||||
|
||||
## Reasonix host checks
|
||||
|
||||
The paths below decide what the agent is allowed to do — a wrong change here
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
Two questions about long-horizon memory, and the harness that makes their
|
||||
answers trustworthy.
|
||||
|
||||
-mode=preflight build all 18 tasks under every arm and check the contract
|
||||
-mode=preflight build all 12 tasks under every arm and check the contract
|
||||
-mode=snippet does a rank-1 hit hand over the whole answer?
|
||||
-mode=adversarial send a model after the answer through every host surface
|
||||
-mode=run-search Experiment R: is searchable canonical a real capability?
|
||||
-mode=run-boundary Experiment I, paired: the 6 efficiency tasks across their boundary
|
||||
-mode=run-index Experiment I, full: the 6 efficiency tasks x 4 budgets
|
||||
-mode=run-boundary Experiment I, paired: 6 tasks across their own cue boundary
|
||||
-mode=run-index Experiment I, full: 6 tasks x 4 index budgets
|
||||
-mode=calibrate find each index task's PlantAfterGen for its cue tier
|
||||
|
||||
`-dry` drives any run mode with a scripted provider, for nothing. Real runs need
|
||||
@@ -31,8 +31,8 @@ The most likely reason is that two of the six index tasks carry heavy-tail
|
||||
stopping behaviour that swamps the effect being measured.
|
||||
|
||||
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.
|
||||
direction. Reopening this wants a new corpus, not more samples: see
|
||||
"Index corpus v2" below.
|
||||
|
||||
**Things that turned out not to be problems**, each after being measured rather
|
||||
than argued about:
|
||||
@@ -74,83 +74,11 @@ scanned for answer literals before the first provider request and again after
|
||||
the turn. Any answer reaching the model through a tool that is not `recall`
|
||||
marks the run contaminated and takes it out of every statistic.
|
||||
|
||||
## Index corpus v2
|
||||
## Index corpus v2, if this is reopened
|
||||
|
||||
The efficiency substrate is six tasks that hold on both arms: `i37-dispatchlag`
|
||||
and `i118-fencelag` (quarter), `i106-budgetseal` and `i70-ingestfloor` (half),
|
||||
`i47-tenantlease` and `i112-lagquota` (default).
|
||||
|
||||
They came out of five batches of candidates, each screened the same way: one
|
||||
`-mode=run-index` pass over the batch, then two more only for the candidates that
|
||||
were clean on both boundary arms, and only the candidates that held on both arms
|
||||
in all three passes stayed. A candidate that does not hold is dropped, not
|
||||
promoted. The numbers are candidates, per tier:
|
||||
|
||||
| batch | quarter | half | default |
|
||||
| --- | --- | --- | --- |
|
||||
| first pool, `i31`-`i54` | 1 of 8 | 0 of 8 | 2 of 8 |
|
||||
| second pool, `i61`-`i84` | 1 of 4 screened | 4 of 8 | 0 of 8 |
|
||||
| third pool, `i85`-`i108` | 2 of 8 | 8 of 16 | none authored |
|
||||
| replacement pool, `i109`-`i116` | - | - | 4 of 8 |
|
||||
| replacement pool, `i92`, `i117`-`i124` | 3 of 9 | - | - |
|
||||
|
||||
That table counts candidates that held on both arms in all three passes, which is
|
||||
not the size of the substrate. 89 candidates were authored, 77 were screened (the
|
||||
second pool's quarter tier stopped at 4 of 8 and its default tier was never run),
|
||||
and 25 held: 7 quarter, 12 half, 6 default. The substrate keeps two per tier, so
|
||||
the other 19 survivors are spares rather than corpus members. Candidates from the
|
||||
earlier round are not counted here.
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `i37-dispatchlag` | quarter | 3/3 | 3/3 |
|
||||
| `i118-fencelag` | quarter | 3/3 | 3/3 |
|
||||
| `i106-budgetseal` | half | 3/3 | 3/3 |
|
||||
| `i70-ingestfloor` | half | 3/3 | 3/3 |
|
||||
| `i47-tenantlease` | default | 3/3 | 3/3 |
|
||||
| `i112-lagquota` | default | 3/3 | 3/3 |
|
||||
|
||||
Across their 36 judged cells: `SnippetStop` 23, `SnippetThenRead` 10 and
|
||||
`DefensiveRead` 3 - every cell clean, nothing post-sufficient and nothing
|
||||
`NeverSufficient`.
|
||||
|
||||
Four of the first six were flagged in review for sharing answer values, probe
|
||||
queries and prompt wording within a tier. Separating them and re-screening those
|
||||
four changed two of them: `i37` and `i47` held, `i62` and `i51` did not, so they
|
||||
were replaced - `i90-recoverylag` was tried first and did not hold either, and
|
||||
`i118-fencelag` and `i112-lagquota` are what did. The separation was not
|
||||
cosmetic: the shared wording had been carrying two of the six.
|
||||
|
||||
Nothing in the task shape separates the survivors from the rest: within the half
|
||||
tier, plain-number answers survived 0 of 8 in the first pool and 4 of 8 in the
|
||||
second, and codename answers survived 8 of 16 in the third. An earlier claim here
|
||||
that the answer kind decides whether a candidate holds does not survive this
|
||||
table and is withdrawn. The dirty class was `PostSufficientRetrieval` in most
|
||||
failures - the model re-checking a value it already had - but which tasks hit it
|
||||
was not predictable from their shape at this sample.
|
||||
|
||||
`i01`-`i06` are the frozen study's six tasks, kept as `roleStopping`. Measured
|
||||
once each with `-mode=run-index -task <id>`, per task and per arm:
|
||||
|
||||
| task | tier | cue present | cue absent |
|
||||
| --- | --- | --- | --- |
|
||||
| `i01-transport-fallback` | quarter | SnippetStop | DefensiveRead |
|
||||
| `i02-scheduler-handoff` | quarter | PostSufficientRetrieval | PostSufficientRetrieval |
|
||||
| `i03-coalescing` | half | PostSufficientRetrieval | PostSufficientRunaway |
|
||||
| `i04-recovery-fence` | half | DefensiveRead | PostSufficientRunaway |
|
||||
| `i05-cache-probe` | default | SnippetThenRead | PostSufficientRetrieval |
|
||||
| `i06-dispatch-handoff` | default | SnippetStop | PostSufficientRetrieval |
|
||||
|
||||
Five of the six show post-sufficient behaviour in at least one arm (`i02`-`i06`),
|
||||
and `i01` is clean on both. The cue-present arm is clean for four of them
|
||||
(`i01`, `i04`, `i05`, `i06`), which is why they stay in the corpus as the
|
||||
stopping side rather than being retitled. All six are excluded from `run-index`
|
||||
and `run-boundary` batches and stay measurable one at a time with `-task <id>`. A
|
||||
task added to the corpus declares a role, so it cannot arrive without one.
|
||||
|
||||
A task qualifies only if it is clean on both of its boundary arms - the
|
||||
cue-present run and the cue-absent run - in every run of a screening batch. Clean
|
||||
means a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
The present index tasks are not a fit substrate: two of six carry stopping
|
||||
behaviour that dominates the signal. A task should qualify only if a run-index
|
||||
run reports a `stopping_class` other than `PostSufficientRetrieval` or
|
||||
`PostSufficientRunaway`: `SnippetStop`, `SnippetThenRead` and `DefensiveRead`
|
||||
all qualify, and a defensive read is deliberately not counted as waste. The
|
||||
class comes from the three counts in that report — `searches_after_sufficient`,
|
||||
@@ -158,7 +86,8 @@ class comes from the three counts in that report — `searches_after_sufficient`
|
||||
sufficiency was reached and how many rounds followed it. The remaining
|
||||
requirements still apply — successful tool call, answer in the output rather
|
||||
than the cue, rank 1, `FirstHitCoverage` complete, one or two simple values,
|
||||
nothing in the workspace.
|
||||
nothing in the workspace. Keep i03 and i04 — as a stopping corpus, not an
|
||||
efficiency one.
|
||||
|
||||
`-mode=calibrate` is not a screening tool: it builds and reads back the
|
||||
fixtures, and reads nothing else, so its cue-visibility profile is the same for
|
||||
|
||||
@@ -26,12 +26,6 @@ const (
|
||||
tierDefault = "default" // visible at default only
|
||||
)
|
||||
|
||||
// role places an index task in the efficiency substrate or the stopping corpus.
|
||||
const (
|
||||
roleEfficiency = "efficiency"
|
||||
roleStopping = "stopping"
|
||||
)
|
||||
|
||||
// plantKind is how a task's target enters the transcript.
|
||||
const (
|
||||
plantAssistant = "assistant" // the model's own account: never index-addressed
|
||||
@@ -51,8 +45,6 @@ type contextTask struct {
|
||||
AnswerVars []string
|
||||
CueVar string
|
||||
CueTier string
|
||||
// Role is roleEfficiency or roleStopping; it decides the batch a task enters.
|
||||
Role string
|
||||
// PlantAfterGen ages the target: mergeFoldIndex trims from its oldest end,
|
||||
// so what follows a cue decides which budget addresses it. Frozen, because
|
||||
// a run that searched for it would let the tested code move its goalposts.
|
||||
@@ -129,20 +121,6 @@ func num(name string, lo, hi int) varSpec {
|
||||
return varSpec{Name: name, Kind: varInt, Min: lo, Max: hi}
|
||||
}
|
||||
|
||||
// efficiencyTasks is the substrate a study measures on. Every task in it
|
||||
// qualifies under the corpus bar; the stopping corpus is deliberately excluded,
|
||||
// because a task carrying post-sufficient stopping behaviour swamps the effect
|
||||
// an efficiency batch exists to measure.
|
||||
func efficiencyTasks() []contextTask {
|
||||
var out []contextTask
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func searchTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
@@ -214,104 +192,10 @@ func searchTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
// efficiencyIndexTasks is what a study measures on: two tasks per cue tier.
|
||||
func efficiencyIndexTasks() []contextTask {
|
||||
out := indexQuarterTasks()
|
||||
out = append(out, indexHalfTasks()...)
|
||||
out = append(out, indexDefaultTasks()...)
|
||||
return out
|
||||
}
|
||||
|
||||
// indexQuarterTasks holds the quarter-tier literals.
|
||||
func indexQuarterTasks() []contextTask {
|
||||
func indexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i37-dispatchlag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/dispatchlag,它打印出的 dispatch lag epoch 是多少?",
|
||||
ProbeQuery: "dispatchlag dispatch lag epoch",
|
||||
Vars: []varSpec{code("cue", "dispatchlag"), code("epoch", "cobalt")},
|
||||
AnswerVars: []string{"epoch"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "p37", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/dispatchlag {{cue}}"}`,
|
||||
PlantBody: "dispatch lag epoch = {{epoch}}",
|
||||
},
|
||||
{
|
||||
ID: "i118-fencelag", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里运行过 ./scripts/fencelag,它打印出的 fence lag 是多少?",
|
||||
ProbeQuery: "fencelag fence lag",
|
||||
Vars: []varSpec{code("cue", "fencelag"), code("v", "slate")},
|
||||
AnswerVars: []string{"v"}, CueVar: "cue", CueTier: tierQuarter,
|
||||
PlantAfterGen: 4,
|
||||
Plant: plantCall, CallID: "x118", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/fencelag {{cue}}"}`,
|
||||
PlantBody: "fence lag = {{v}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexHalfTasks holds the half-tier literals.
|
||||
func indexHalfTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i70-ingestfloor", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/ingestfloor,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "ingestfloor quota",
|
||||
Vars: []varSpec{code("cue", "ingestfloor"), num("quota", 1200, 9800)},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "q70", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/ingestfloor {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
{
|
||||
ID: "i106-budgetseal", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/budgetseal,它打印出的 route quota 是多少?",
|
||||
ProbeQuery: "budgetseal route quota",
|
||||
Vars: []varSpec{code("cue", "budgetseal"), code("quota", "amber")},
|
||||
AnswerVars: []string{"quota"}, CueVar: "cue", CueTier: tierHalf,
|
||||
PlantAfterGen: 2,
|
||||
Plant: plantCall, CallID: "h106", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/budgetseal {{cue}}"}`,
|
||||
PlantBody: "route quota = {{quota}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// indexDefaultTasks holds the default-tier literals.
|
||||
func indexDefaultTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i47-tenantlease", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/tenantlease,它打印出的 tenant lease(秒)是多少?",
|
||||
ProbeQuery: "tenantlease tenant lease",
|
||||
Vars: []varSpec{code("cue", "tenantlease"), {Name: "lease", Kind: varDuration, Min: 15, Max: 300}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "p47", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/tenantlease {{cue}}"}`,
|
||||
PlantBody: "tenant lease = {{lease}}",
|
||||
},
|
||||
{
|
||||
ID: "i112-lagquota", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleEfficiency,
|
||||
Prompt: "早前会话里执行过 ./scripts/lagquota,它打印出的 lagquota lease 是多少?",
|
||||
ProbeQuery: "lagquota lagquota lease",
|
||||
Vars: []varSpec{code("cue", "lagquota"), {Name: "lease", Kind: varDuration, Min: 20, Max: 420}},
|
||||
AnswerVars: []string{"lease"}, CueVar: "cue", CueTier: tierDefault,
|
||||
PlantAfterGen: 0,
|
||||
Plant: plantCall, CallID: "d112", CallTool: "bash",
|
||||
CallArgs: `{"command":"./scripts/lagquota {{cue}}"}`,
|
||||
PlantBody: "lagquota lease = {{lease}}",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// stoppingIndexTasks is the frozen study's six tasks, kept out of efficiency
|
||||
// batches and measurable on their own with -task <id>.
|
||||
func stoppingIndexTasks() []contextTask {
|
||||
return []contextTask{
|
||||
{
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i01-transport-fallback", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "transport fallback probe 当时报告的 selector 是什么?",
|
||||
ProbeQuery: "transport fallback probe selector",
|
||||
Vars: []varSpec{code("cue", "tfprobe"), code("selector", "fern")},
|
||||
@@ -322,7 +206,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "fallback selector = {{selector}}",
|
||||
},
|
||||
{
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i02-scheduler-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "scheduler handoff 当时记录的 grace 和 marker 是什么?",
|
||||
ProbeQuery: "scheduler handoff grace marker",
|
||||
Vars: []varSpec{
|
||||
@@ -336,7 +220,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "handoff grace = {{grace}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i03-coalescing", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "job completion 的 coalescing probe 最后记录的 window 和 marker 是多少?",
|
||||
ProbeQuery: "coalescing policy completion window",
|
||||
Vars: []varSpec{
|
||||
@@ -350,7 +234,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "completion coalesce window = {{window}}\nmarker = {{marker}}",
|
||||
},
|
||||
{
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i04-recovery-fence", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "recovery fence 那次检查报告的 marker 和两个 epoch 是什么?",
|
||||
ProbeQuery: "recovery fence epoch check",
|
||||
Vars: []varSpec{
|
||||
@@ -364,7 +248,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "{{marker}}\nexpected epoch {{expected}}\nobserved epoch {{observed}}",
|
||||
},
|
||||
{
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i05-cache-probe", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "cache probe 当时记录的 prefix salt 和 scope 是什么?",
|
||||
ProbeQuery: "cache probe prefix salt scope",
|
||||
Vars: []varSpec{code("cue", "cacheprobe"), code("salt", "comet"), code("scope", "lineage")},
|
||||
@@ -375,7 +259,7 @@ func stoppingIndexTasks() []contextTask {
|
||||
PlantBody: "prefix salt = {{salt}}\nscope = {{scope}}",
|
||||
},
|
||||
{
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input", Role: roleStopping,
|
||||
ID: "i06-dispatch-handoff", Experiment: experimentIndex, TargetKind: "tool_input",
|
||||
Prompt: "dispatch handoff probe 当时确认的 mode 和 marker 是什么?",
|
||||
ProbeQuery: "dispatch handoff probe mode",
|
||||
Vars: []varSpec{code("cue", "dispatchhandoff"), code("mode", "trigger"), code("marker", "iris")},
|
||||
@@ -388,12 +272,6 @@ func stoppingIndexTasks() []contextTask {
|
||||
}
|
||||
}
|
||||
|
||||
// indexTasks is the whole index corpus, the efficiency substrate first.
|
||||
func indexTasks() []contextTask {
|
||||
out := efficiencyIndexTasks()
|
||||
return append(out, stoppingIndexTasks()...)
|
||||
}
|
||||
|
||||
func allTasks() []contextTask {
|
||||
return append(searchTasks(), indexTasks()...)
|
||||
}
|
||||
@@ -454,9 +332,6 @@ func validateCorpus() error {
|
||||
if t.CueVar == "" {
|
||||
return fmt.Errorf("%s: an index task needs a cue var", t.ID)
|
||||
}
|
||||
if t.Role != roleEfficiency && t.Role != roleStopping {
|
||||
return fmt.Errorf("%s: an index task needs a role (%s or %s)", t.ID, roleEfficiency, roleStopping)
|
||||
}
|
||||
if v, _ := tierScales(t.CueTier); v == nil {
|
||||
return fmt.Errorf("%s: unknown cue tier %q", t.ID, t.CueTier)
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func (p *digestProvider) Name() string { return "fixture-digest" }
|
||||
func (p *digestProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
p.calls++
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "Earlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"io"
|
||||
mathrand "math/rand"
|
||||
"strings"
|
||||
)
|
||||
@@ -91,25 +90,15 @@ func seededRand(parts ...string) *mathrand.Rand {
|
||||
// liveRand is the generator a paid run uses: every run gets values that existed
|
||||
// nowhere before it started.
|
||||
func liveRand() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(rand.Reader)
|
||||
if err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
return rng
|
||||
}
|
||||
|
||||
// liveRandFrom seeds a generator from an entropy source, so what a run's values
|
||||
// are unpredictable from is the source and nothing else.
|
||||
func liveRandFrom(entropy io.Reader) (*mathrand.Rand, error) {
|
||||
var seed [8]byte
|
||||
if _, err := io.ReadFull(entropy, seed[:]); err != nil {
|
||||
return nil, err
|
||||
if _, err := rand.Read(seed[:]); err != nil {
|
||||
panic("contextbench: no entropy for a live run: " + err.Error())
|
||||
}
|
||||
var n int64
|
||||
for _, b := range seed {
|
||||
n = n<<8 | int64(b)
|
||||
}
|
||||
return mathrand.New(mathrand.NewSource(n)), nil
|
||||
return mathrand.New(mathrand.NewSource(n))
|
||||
}
|
||||
|
||||
// instantiate substitutes {{name}} and refuses anything left over. The grammar
|
||||
|
||||
@@ -62,14 +62,7 @@ func main() {
|
||||
case "adversarial":
|
||||
os.Exit(runAdversarial(root))
|
||||
case "run-index":
|
||||
batch := tasks
|
||||
if *only == "" {
|
||||
// A batch measures the efficiency substrate only. Naming one task
|
||||
// with -task runs that task, role included, so the stopping corpus
|
||||
// stays measurable on its own.
|
||||
batch = efficiencyTasks()
|
||||
}
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, batch))
|
||||
os.Exit(runExperiment(experimentIndex, root, *dry, tasks))
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown mode %q\n", *mode)
|
||||
os.Exit(2)
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"maps"
|
||||
mathrand "math/rand"
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -22,24 +19,16 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
if got := len(searchTasks()); got != 6 {
|
||||
t.Errorf("search experiment has %d tasks, want 6", got)
|
||||
}
|
||||
if got := len(indexTasks()); got != 12 {
|
||||
t.Errorf("index experiment has %d tasks, want 12", got)
|
||||
if got := len(indexTasks()); got != 6 {
|
||||
t.Errorf("index experiment has %d tasks, want 6", got)
|
||||
}
|
||||
stopping := map[string]bool{}
|
||||
tiers := map[string]int{}
|
||||
for _, task := range indexTasks() {
|
||||
if task.Role == roleStopping {
|
||||
stopping[task.ID] = true
|
||||
continue
|
||||
}
|
||||
tiers[task.CueTier]++
|
||||
}
|
||||
if !stopping["i03-coalescing"] || !stopping["i04-recovery-fence"] {
|
||||
t.Errorf("the stopping corpus is %v; it must keep i03-coalescing and i04-recovery-fence", stopping)
|
||||
}
|
||||
// Two per tier, over the efficiency substrate, is what makes the staircase
|
||||
// readable: the same task can be compared across the boundary its cue sits
|
||||
// on, instead of two different questions being compared across two arms.
|
||||
// Two per tier is what makes the staircase readable: the same task can be
|
||||
// compared across the boundary its cue sits on, instead of two different
|
||||
// questions being compared across two arms.
|
||||
for _, tier := range []string{tierQuarter, tierHalf, tierDefault} {
|
||||
if tiers[tier] != 2 {
|
||||
t.Errorf("tier %s has %d tasks, want 2", tier, tiers[tier])
|
||||
@@ -47,21 +36,6 @@ func TestCorpusIsWellFormed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The role decides which batch a task enters; the literal lists are only where
|
||||
// it is declared, so the two must not drift.
|
||||
func TestIndexListsMatchTheirRoles(t *testing.T) {
|
||||
for _, task := range append(append(indexQuarterTasks(), indexHalfTasks()...), indexDefaultTasks()...) {
|
||||
if task.Role != roleEfficiency {
|
||||
t.Errorf("%s sits in an efficiency list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
for _, task := range stoppingIndexTasks() {
|
||||
if task.Role != roleStopping {
|
||||
t.Errorf("%s sits in the stopping list with role %q", task.ID, task.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The invariant the whole instantiation layer exists for: no literal a scorer
|
||||
// needs may be readable in this repository before a run starts.
|
||||
func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
@@ -85,46 +59,20 @@ func TestNoAnswerLiteralExistsInTheRepository(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A run's values are unpredictable from a previous run only if the live
|
||||
// generator is seeded from entropy. Comparing answers across two instances
|
||||
// cannot assert that: a task whose answer is a bounded number collides by
|
||||
// chance (1/8601 to 1/286 per task), so the check is on the seed source.
|
||||
func TestLiveRandDrawsFromEntropy(t *testing.T) {
|
||||
a, b := liveRand(), liveRand()
|
||||
for range 4 {
|
||||
if a.Int63() != b.Int63() {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("two live generators produced the same stream: they are seeded alike")
|
||||
}
|
||||
|
||||
// The source alone decides an instance: the same entropy gives the same
|
||||
// answers, different entropy gives different values.
|
||||
func TestLiveRandSourceDecidesTheInstance(t *testing.T) {
|
||||
same := func() *mathrand.Rand {
|
||||
rng, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{7}, 8)))
|
||||
// Two runs of the same task must not share an answer, or one leaked run leaks
|
||||
// every future one.
|
||||
func TestInstancesDifferBetweenRuns(t *testing.T) {
|
||||
for _, task := range allTasks() {
|
||||
a, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rng
|
||||
}
|
||||
other, err := liveRandFrom(bytes.NewReader(bytes.Repeat([]byte{9}, 8)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := liveRandFrom(bytes.NewReader(nil)); err == nil {
|
||||
t.Fatal("an empty entropy source was accepted")
|
||||
}
|
||||
for _, task := range allTasks() {
|
||||
a, _ := instantiateTask(task, same())
|
||||
b, _ := instantiateTask(task, same())
|
||||
if !slices.Equal(a.AnswerMarkers, b.AnswerMarkers) || !maps.Equal(a.Vars, b.Vars) {
|
||||
t.Errorf("%s: the same entropy gave different instances", task.ID)
|
||||
b, err := instantiateTask(task, liveRand())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := instantiateTask(task, other)
|
||||
if maps.Equal(a.Vars, c.Vars) {
|
||||
t.Errorf("%s: different entropy gave the same values", task.ID)
|
||||
if slices.Equal(a.AnswerMarkers, b.AnswerMarkers) {
|
||||
t.Errorf("%s produced the same answers twice: %v", task.ID, a.AnswerMarkers)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,7 +232,7 @@ func runBoundaries(root string) int {
|
||||
|
||||
var all []contextMetrics
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
for _, t := range efficiencyTasks() {
|
||||
for _, t := range indexTasks() {
|
||||
cue, noCue := boundaryPair(t.CueTier)
|
||||
for _, side := range []struct {
|
||||
scale string
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
alpha=QORVEX-7741
|
||||
beta=MIRALD-3208
|
||||
gamma=TESSIK-9155
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
|
||||
class = "cjk"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CJK5T"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the deploy region for this project (just the region string) into answer.txt."
|
||||
class = "conflict"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONF2P"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the command to install this project's dependencies into answer.txt."
|
||||
class = "contradiction"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CONTRA"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "distractor"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-DIST5Q"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write this project's canonical fast-check command (the exact command line, nothing else) into answer.txt."
|
||||
class = "exact"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-EXACT7Q"]
|
||||
@@ -1,3 +1,4 @@
|
||||
prompt = "Add a function add(a, b) returning a+b to util.py."
|
||||
class = "generic"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Copy the exact server timeout configuration line from config/server.conf into answer.txt, unchanged."
|
||||
class = "history"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-HIST6M"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Which tool do we install dependencies with here? Write the exact install command into answer.txt."
|
||||
class = "paraphrase"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PARA3X"]
|
||||
@@ -1,5 +1,6 @@
|
||||
prompt = "Create a file named notes.txt containing one line: hello."
|
||||
class = "pinned"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-PIN4D"]
|
||||
memory_markers_prefix = true
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Confirm the current release branch of this repo and write it into answer.txt."
|
||||
class = "stale"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-STALE1"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Set the retry max backoff to 250 in this project's configuration."
|
||||
class = "symbol"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-SYM9K"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Write the current API base URL for this project into answer.txt."
|
||||
class = "update"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-UPD8R"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "查一下灰度的放量比例,写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1GR"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1XL"]
|
||||
@@ -1,4 +1,5 @@
|
||||
prompt = "Set the max idle connection seconds to 30 in this project's configuration."
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1SYM"]
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,3 +0,0 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -1,2 +0,0 @@
|
||||
value=7
|
||||
file=limits/budget.py
|
||||
@@ -1,2 +0,0 @@
|
||||
handler=h07.py
|
||||
missing=validate_payload
|
||||
@@ -1,8 +0,0 @@
|
||||
def wire_fee(amount):
|
||||
"""Return the wire fee for `amount`: the greater of 15.0 and 0.1% of it.
|
||||
|
||||
A zero or negative amount has no wire fee, so it returns 0.0.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0.0
|
||||
return max(15.0, amount * 0.001)
|
||||
@@ -1,13 +0,0 @@
|
||||
def convert(amount, rate):
|
||||
"""Convert `amount` at `rate` and return the product.
|
||||
|
||||
Both units belong to the caller. A negative amount converts unchanged.
|
||||
"""
|
||||
return amount * rate
|
||||
|
||||
def spread(bid, ask):
|
||||
"""Return `ask - bid`, the spread between the two quoted prices.
|
||||
|
||||
A negative result means the bid is above the ask.
|
||||
"""
|
||||
return ask - bid
|
||||
@@ -1,4 +0,0 @@
|
||||
EARTH_RADIUS_KM = 6371.0
|
||||
|
||||
def dist(left, right):
|
||||
return abs(left - right) * EARTH_RADIUS_KM
|
||||
@@ -1,4 +0,0 @@
|
||||
import geo
|
||||
|
||||
def near_by(point, other):
|
||||
return geo.dist(point, other) < 10
|
||||
@@ -1,16 +0,0 @@
|
||||
import json
|
||||
import sys
|
||||
|
||||
def upgrade(row):
|
||||
parts = row["fullname"].rsplit(" ", 1)
|
||||
out = {k: v for k, v in row.items() if k != "fullname"}
|
||||
out["first"] = parts[0]
|
||||
out["last"] = parts[1] if len(parts) > 1 else ""
|
||||
return out
|
||||
|
||||
def main():
|
||||
for line in sys.stdin:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
print(json.dumps(upgrade(json.loads(line))))
|
||||
@@ -1,7 +0,0 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("orders")
|
||||
|
||||
def place(order, request_id):
|
||||
log.info("placing order %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -1,7 +0,0 @@
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("shipping")
|
||||
|
||||
def dispatch(order, request_id):
|
||||
log.info("dispatching %s", order["id"], extra={"request_id": request_id})
|
||||
return {"ok": True}
|
||||
@@ -1,11 +0,0 @@
|
||||
import math
|
||||
|
||||
def p95(sorted_values):
|
||||
if not sorted_values:
|
||||
return 0
|
||||
return sorted_values[math.ceil(len(sorted_values) * 0.95) - 1]
|
||||
|
||||
def rate(events, seconds):
|
||||
if seconds <= 0:
|
||||
return 0.0
|
||||
return len(events) / seconds
|
||||
@@ -1,9 +0,0 @@
|
||||
import core
|
||||
|
||||
_cache = None
|
||||
|
||||
def list_records_cached():
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = core.list_records()
|
||||
return _cache
|
||||
@@ -1,5 +0,0 @@
|
||||
import cache
|
||||
|
||||
def main():
|
||||
for k, v in sorted(cache.list_records_cached().items()):
|
||||
print(f"{k}={v}")
|
||||
@@ -1,4 +0,0 @@
|
||||
DATA = {"a": 1, "b": 2}
|
||||
|
||||
def list_records():
|
||||
return dict(DATA)
|
||||
@@ -1 +0,0 @@
|
||||
DEFAULTS = {"host": "localhost", "port": 8080}
|
||||
@@ -1,6 +0,0 @@
|
||||
import json
|
||||
import defaults
|
||||
|
||||
def load(text):
|
||||
data = json.loads(text) if text.strip() else {}
|
||||
return dict(defaults.DEFAULTS, **data)
|
||||
@@ -1,4 +0,0 @@
|
||||
import json
|
||||
|
||||
def dump(cfg):
|
||||
return json.dumps(cfg, sort_keys=True)
|
||||
@@ -1,2 +0,0 @@
|
||||
STATES = ("new", "paid", "delivered", "cancelled")
|
||||
TERMINAL = ("delivered", "cancelled")
|
||||
@@ -1,11 +0,0 @@
|
||||
import states
|
||||
|
||||
LEGAL = {
|
||||
"new": ("paid", "cancelled"),
|
||||
"paid": ("delivered", "cancelled"),
|
||||
"delivered": (),
|
||||
"cancelled": (),
|
||||
}
|
||||
|
||||
def can_move(a, b):
|
||||
return b in LEGAL[a]
|
||||
@@ -1,5 +0,0 @@
|
||||
import schema, serializer
|
||||
|
||||
def display_name(line):
|
||||
rec = serializer.load(line)
|
||||
return rec["handle"] or rec["email"]
|
||||
@@ -1,4 +0,0 @@
|
||||
FIELDS = ("handle", "email")
|
||||
|
||||
def blank():
|
||||
return {f: "" for f in FIELDS}
|
||||
@@ -1,4 +0,0 @@
|
||||
import timings
|
||||
|
||||
def is_slow(entry):
|
||||
return entry["elapsed_s"] > timings.SLOW_SECONDS
|
||||
@@ -1,4 +0,0 @@
|
||||
import timings
|
||||
|
||||
def within_budget(entries):
|
||||
return sum(e["elapsed_s"] for e in entries) <= timings.BUDGET_SECONDS
|
||||
@@ -1,5 +0,0 @@
|
||||
SLOW_SECONDS = 2.0
|
||||
BUDGET_SECONDS = 60.0
|
||||
|
||||
def record(seconds):
|
||||
return {"elapsed_s": seconds}
|
||||
@@ -28,15 +28,6 @@ const trainCorpusDir = "../../benchmarks/train"
|
||||
// grader test instead.
|
||||
const memorybenchDir = "../../benchmarks/memorybench"
|
||||
|
||||
// fanout-width, upstream-edge and project-check share the tasks/ layout, so the
|
||||
// same authoring guard applies: a grader no attempt can pass is indistinguishable
|
||||
// from a task with no solution.
|
||||
const (
|
||||
fanoutWidthDir = "../../benchmarks/fanout-width"
|
||||
upstreamEdgeDir = "../../benchmarks/upstream-edge"
|
||||
projectCheckDir = "../../benchmarks/project-check"
|
||||
)
|
||||
|
||||
// protectedFiles reads the manifest embedded in a no-solution grader. The
|
||||
// manifest lives inside verify.sh precisely because e2ebench drops that file
|
||||
// in only after the run, so the agent never sees which files are watched.
|
||||
@@ -189,7 +180,7 @@ 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 _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -242,7 +233,7 @@ 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 _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -259,10 +250,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 dir == trainCorpusDir {
|
||||
t.Fatal("no solution/: a generated task must prove its grader can pass")
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
t.Skip("no solution/ to check this grader against")
|
||||
}
|
||||
if err := gradeSeed(t, work); err != nil {
|
||||
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
|
||||
@@ -345,7 +336,7 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
|
||||
// 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.
|
||||
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestKernelLanguageWithShellSystemLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", "zh-CN")
|
||||
for _, tt := range []struct{ setting, env, want string }{
|
||||
{"auto", "", "zh"},
|
||||
{"en", "", "en"},
|
||||
{"auto", "en", "en"},
|
||||
{"zh-TW", "en", "zh-TW"},
|
||||
} {
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
if got := resolveKernelLanguage(&config.Config{Language: tt.setting}); got != tt.want {
|
||||
t.Errorf("setting %q, env %q: language = %q, want %q", tt.setting, tt.env, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
// launchWorkspace is the folder the first pane opens. The working directory
|
||||
// counts only when someone chose it: a Start-menu or Finder launch inherits the
|
||||
// install directory or the filesystem root, and taking that as a project puts
|
||||
// the app's own folder in the sidebar again after every removal.
|
||||
func launchWorkspace(appExe string) string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return pickLaunchWorkspace(boot.ResolveWorkspaceRoot(""), appExe, serve.LaunchWorkspaces(), home)
|
||||
}
|
||||
|
||||
func pickLaunchWorkspace(cwd, appExe string, remembered []string, home string) string {
|
||||
if userChoseDir(cwd, appExe) {
|
||||
return cwd
|
||||
}
|
||||
for _, dir := range remembered {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
if home != "" {
|
||||
return home
|
||||
}
|
||||
return cwd
|
||||
}
|
||||
|
||||
func userChoseDir(cwd, appExe string) bool {
|
||||
if cwd == "" || filepath.Dir(cwd) == cwd {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Dir(appExe), cwd)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
inside := rel == "." || (rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)))
|
||||
return !inside
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUserChoseDirRejectsWhatALauncherInherits(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
cases := []struct {
|
||||
name, cwd, exe string
|
||||
want bool
|
||||
}{
|
||||
{"install directory", app, exe, false},
|
||||
{"below the install directory", filepath.Join(app, "resources"), exe, false},
|
||||
{"filesystem root", filepath.VolumeName(base) + string(filepath.Separator), exe, false},
|
||||
{"a project elsewhere", filepath.Join(base, "project"), exe, true},
|
||||
{"a sibling sharing the name prefix", app + "-notes", exe, true},
|
||||
{"unpackaged run keeps the directory", app, "", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := userChoseDir(c.cwd, c.exe); got != c.want {
|
||||
t.Errorf("%s: userChoseDir(%q) = %v, want %v", c.name, c.cwd, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickLaunchWorkspaceFallbackOrder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
app := filepath.Join(base, "ReasonixStudio")
|
||||
exe := filepath.Join(app, "Reasonix Studio.exe")
|
||||
mk := func(name string) string {
|
||||
d := filepath.Join(base, name)
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
a, b, home := mk("a"), mk("b"), mk("home")
|
||||
gone := filepath.Join(base, "gone")
|
||||
proj := mk("proj")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
cwd string
|
||||
remembered []string
|
||||
want string
|
||||
}{
|
||||
{"chosen directory wins over the list", proj, []string{a}, proj},
|
||||
{"list head", app, []string{a, b}, a},
|
||||
{"stale head skipped", app, []string{gone, b}, b},
|
||||
{"all stale falls to home", app, []string{gone}, home},
|
||||
{"empty list falls to home", app, nil, home},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := pickLaunchWorkspace(c.cwd, exe, c.remembered, home); got != c.want {
|
||||
t.Errorf("%s: got %q, want %q", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchWorkspaceKeepsLaunchProjectAfterSidebarReorder(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
home := filepath.Join(base, "state")
|
||||
app := filepath.Join(base, "app")
|
||||
a, b := filepath.Join(base, "a"), filepath.Join(base, "b")
|
||||
for _, dir := range []string{home, app, a, b} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Chdir(app)
|
||||
data, _ := json.Marshal(map[string]any{"paths": []string{b, a}, "launch": a})
|
||||
if err := os.WriteFile(filepath.Join(home, "serve-workspaces.json"), data, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exe := filepath.Join(app, "Reasonix Studio")
|
||||
if got := launchWorkspace(exe); got != a {
|
||||
t.Fatalf("launchWorkspace = %q, want the pre-reorder launch project %q", got, a)
|
||||
}
|
||||
if err := os.Remove(a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := launchWorkspace(exe); got != b {
|
||||
t.Fatalf("missing launch project = %q, want next remembered folder %q", got, b)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,6 @@ import (
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
@@ -35,7 +34,6 @@ import (
|
||||
"reasonix/internal/frontend/traystate"
|
||||
"reasonix/internal/platform/account"
|
||||
"reasonix/internal/platform/appupdate"
|
||||
"reasonix/internal/platform/feedback"
|
||||
"reasonix/internal/platform/instanceid"
|
||||
"reasonix/internal/platform/notify"
|
||||
"reasonix/internal/platform/remotecloud"
|
||||
@@ -149,7 +147,6 @@ func main() {
|
||||
// because that pipe is the only channel pointing that way; every log goes to
|
||||
// logs, which is why no line there can be mistaken for one of these.
|
||||
func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellIdentity) int {
|
||||
phases := newStartupPhases(logs, time.Now)
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if lease != nil {
|
||||
@@ -162,7 +159,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
}
|
||||
|
||||
served, err := serve.FindPage(page)
|
||||
phases.mark(startupPage)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -171,7 +167,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: no built page found; serving the kernel only")
|
||||
}
|
||||
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served, phases)
|
||||
hub, err := assemble(ctx, logs, handshakeTo, shell, served)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -181,7 +177,6 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
defer hub.Shutdown()
|
||||
|
||||
bound, err := bind(hub.Handler())
|
||||
phases.mark(startupBind)
|
||||
if err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
@@ -189,10 +184,7 @@ func run(lease io.Reader, handshakeTo, logs io.Writer, page string, shell shellI
|
||||
// The credential-writing setup surface opens only on a loopback address,
|
||||
// and this is the first host that has one to show it.
|
||||
hub.EnableProviderSetupForListener(bound.listener.Addr().String())
|
||||
phases.mark(startupSetup)
|
||||
err = announce(handshakeTo, bound)
|
||||
phases.mark(startupAnnounce)
|
||||
if err != nil {
|
||||
if err := announce(handshakeTo, bound); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host:", err)
|
||||
return 1
|
||||
}
|
||||
@@ -289,15 +281,13 @@ func resolveKernelLanguage(cfg *config.Config) string {
|
||||
|
||||
// assemble builds the hub this host serves: one pane on the workspace it was
|
||||
// launched in, carrying the capabilities a local window may exercise.
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS, phases *startupPhases) (*serve.Hub, error) {
|
||||
func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdentity, page fs.FS) (*serve.Hub, error) {
|
||||
// The one-time upgrades belong to whichever entry point starts first; a
|
||||
// person who only ever opens the window would otherwise never get them.
|
||||
if _, err := config.ApplyUserConfigUpgradesOnStartup(config.UserConfigPath()); err != nil {
|
||||
fmt.Fprintln(logs, "reasonix-studio-host: config upgrade:", err)
|
||||
}
|
||||
phases.mark(startupConfigUpgrade)
|
||||
cfg, err := config.Load()
|
||||
phases.mark(startupConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -316,29 +306,20 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
bc := serve.NewBroadcaster()
|
||||
paneSink := decorate(bc)
|
||||
if cfg.DesktopTelemetry() || cfg.DesktopMetrics() {
|
||||
reporter := telemetry.Start(studioTelemetryOptions(ctx, cfg, shell.version))
|
||||
reporter := telemetry.Start(studioTelemetryOptions(cfg, shell.version))
|
||||
if cfg.DesktopMetrics() {
|
||||
paneSink = reporter.Wrap(paneSink)
|
||||
}
|
||||
}
|
||||
phases.mark(startupSinks)
|
||||
root := launchWorkspace(shell.exe)
|
||||
phases.mark(startupWorkspace)
|
||||
root := boot.ResolveWorkspaceRoot("")
|
||||
built, err := boot.BuildRuntime(ctx, boot.Options{
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
CleanupPendingReconciler: serve.BackgroundCleanupReconciler,
|
||||
Version: version,
|
||||
WorkspaceRoot: root,
|
||||
SessionDir: serve.SessionDirFor(root),
|
||||
Sink: paneSink,
|
||||
Stderr: logs,
|
||||
StatsSource: surface.Desktop,
|
||||
})
|
||||
phases.mark(startupRuntime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -353,7 +334,6 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// Shut until the person at the window opens it; the context ending closes
|
||||
// it with the rest of the kernel, unpairing every device.
|
||||
share := serve.NewDeviceShare(page)
|
||||
share.RestorePort(cfg.SharePort())
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
share.Close()
|
||||
@@ -378,16 +358,12 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
srv.SetPaneSink(paneSink)
|
||||
srv.AdoptRuntime(built)
|
||||
phases.mark(startupHub)
|
||||
_, err = hub.Adopt(srv, bc)
|
||||
phases.mark(startupAdopt)
|
||||
if err != nil {
|
||||
if _, err := hub.Adopt(srv, bc); err != nil {
|
||||
hub.Shutdown()
|
||||
return nil, err
|
||||
}
|
||||
hub.StartRecoveryGC(ctx)
|
||||
startCloudRemote(ctx, cfg, logs, hub)
|
||||
phases.mark(startupBackground)
|
||||
return hub, nil
|
||||
}
|
||||
|
||||
@@ -423,22 +399,12 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
// desktopTelemetryOn reads the user-level setting as it is now. A file that does
|
||||
// not parse is not consent, and neither the project config nor a migration
|
||||
// rewrite is involved; a missing file keeps the documented default (on).
|
||||
func desktopTelemetryOn() bool {
|
||||
cfg, err := config.LoadForEditReadOnlyStrict(config.UserConfigPath())
|
||||
return err == nil && cfg != nil && cfg.DesktopTelemetry()
|
||||
}
|
||||
|
||||
func studioTelemetryOptions(ctx context.Context, cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
func studioTelemetryOptions(cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
return telemetry.Options{
|
||||
Context: ctx,
|
||||
Mode: "on",
|
||||
Version: studioVersion,
|
||||
Surface: surface.Studio,
|
||||
SuppressPing: !cfg.DesktopTelemetry(),
|
||||
PingAllowed: desktopTelemetryOn,
|
||||
HomeDir: config.ReasonixHomeDir(),
|
||||
Interactive: true,
|
||||
Proxy: cfg.NetworkProxySpec(),
|
||||
|
||||
@@ -26,67 +26,12 @@ func TestMain(m *testing.M) {
|
||||
testenv.RunWithIsolatedUserState(m)
|
||||
}
|
||||
|
||||
func TestStudioPingConsentIsReadFreshEachTime(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
write := func(body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
opts := studioTelemetryOptions(context.Background(), config.Default(), "2.20.2")
|
||||
if opts.PingAllowed == nil {
|
||||
t.Fatal("Studio's ping has no live consent check")
|
||||
}
|
||||
write("[desktop]\ntelemetry = true\n")
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("enabled setting reads as off")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a setting turned off while running still allows the ping")
|
||||
}
|
||||
write("[desktop]\ntelemetry = false\n\n[broken\nx = \n")
|
||||
if opts.PingAllowed() {
|
||||
t.Fatal("a corrupt file after an opt-out counted as consent")
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !opts.PingAllowed() {
|
||||
t.Fatal("a missing file should keep the documented default (on)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectConfigCannotTurnThePingOn(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[desktop]\ntelemetry = false\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte("[desktop]\ntelemetry = true\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(project)
|
||||
if desktopTelemetryOn() {
|
||||
t.Fatal("a checkout's reasonix.toml turned the ping back on")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioTelemetryUsesItsOwnSurfaceAndConsent(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry = &disabled
|
||||
|
||||
opts := studioTelemetryOptions(context.Background(), cfg, "2.20.2")
|
||||
opts := studioTelemetryOptions(cfg, "2.20.2")
|
||||
if opts.Surface != surface.Studio {
|
||||
t.Fatalf("surface = %q, want studio", opts.Surface)
|
||||
}
|
||||
@@ -542,7 +487,7 @@ func TestAssembleAppliesTheStartupUpgrades(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
hub, err := assemble(t.Context(), io.Discard, io.Discard, shellIdentity{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
)
|
||||
|
||||
type startupPhase string
|
||||
|
||||
const (
|
||||
startupPage startupPhase = "page"
|
||||
startupConfigUpgrade startupPhase = "config-upgrade"
|
||||
startupConfig startupPhase = "config"
|
||||
startupSinks startupPhase = "sinks"
|
||||
startupWorkspace startupPhase = "workspace"
|
||||
startupRuntime startupPhase = "runtime"
|
||||
startupHub startupPhase = "hub"
|
||||
startupAdopt startupPhase = "adopt"
|
||||
startupBackground startupPhase = "background"
|
||||
startupBind startupPhase = "bind"
|
||||
startupSetup startupPhase = "provider-setup"
|
||||
startupAnnounce startupPhase = "announce"
|
||||
)
|
||||
|
||||
type startupPhases struct {
|
||||
logs io.Writer
|
||||
now func() time.Time
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newStartupPhases(logs io.Writer, now func() time.Time) *startupPhases {
|
||||
return &startupPhases{logs: logs, now: now, last: now()}
|
||||
}
|
||||
|
||||
func (p *startupPhases) mark(phase startupPhase) {
|
||||
now := p.now()
|
||||
p.write(phase, now.Sub(p.last))
|
||||
p.last = now
|
||||
}
|
||||
|
||||
func (p *startupPhases) write(phase startupPhase, elapsed time.Duration) {
|
||||
fmt.Fprintf(p.logs, "reasonix-studio-host: startup phase=%s elapsed_ms=%d\n", phase, elapsed.Milliseconds())
|
||||
}
|
||||
|
||||
func (p *startupPhases) boot(phase boot.Phase) {
|
||||
p.write(startupPhase("boot."+phase.Name), phase.D)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/assembly/boot"
|
||||
)
|
||||
|
||||
func TestStartupPhasesUseElapsedMilliseconds(t *testing.T) {
|
||||
var logs bytes.Buffer
|
||||
now := time.Unix(100, 0)
|
||||
p := newStartupPhases(&logs, func() time.Time { return now })
|
||||
now = now.Add(1250 * time.Millisecond)
|
||||
p.mark(startupPage)
|
||||
now = now.Add(7 * time.Millisecond)
|
||||
p.boot(boot.Phase{Name: "sessions", D: 3 * time.Millisecond})
|
||||
p.mark(startupRuntime)
|
||||
want := "reasonix-studio-host: startup phase=page elapsed_ms=1250\n" +
|
||||
"reasonix-studio-host: startup phase=boot.sessions elapsed_ms=3\n" +
|
||||
"reasonix-studio-host: startup phase=runtime elapsed_ms=7\n"
|
||||
if got := logs.String(); got != want {
|
||||
t.Fatalf("phase log = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/frontend/serve"
|
||||
)
|
||||
|
||||
func TestAssembledTrayLanguage(t *testing.T) {
|
||||
before := i18n.CurrentLanguage()
|
||||
t.Cleanup(func() { i18n.DetectLanguage(before) })
|
||||
for _, tt := range []struct{ name, system, desktop, env, locale, want string }{
|
||||
{"system", "zh-Hans-CN", "auto", "", "", "zh"},
|
||||
{"traditional", "zh-Hant-TW", "auto", "", "", "zh-TW"},
|
||||
{"desktop", "zh-Hans-CN", "en", "", "", "en"},
|
||||
{"reasonix", "zh-Hans-CN", "auto", "en-US", "", "en"},
|
||||
{"desktop-over-env", "en-US", "zh", "en", "", "zh"},
|
||||
{"c-locale", "zh-Hans-CN", "auto", "", "C", "en"},
|
||||
{"posix-locale", "zh-Hans-CN", "auto", "", "POSIX.UTF-8", "en"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
t.Chdir(testenv.TempDir(t))
|
||||
for _, key := range []string{"REASONIX_LANG", "LC_ALL", "LC_MESSAGES", "LANG"} {
|
||||
t.Setenv(key, "")
|
||||
}
|
||||
t.Setenv("REASONIX_SYSTEM_LANG", tt.system)
|
||||
t.Setenv("REASONIX_LANG", tt.env)
|
||||
t.Setenv("LANG", tt.locale)
|
||||
cfg := config.Default()
|
||||
disabled := false
|
||||
cfg.Desktop.Telemetry, cfg.Desktop.Metrics = &disabled, &disabled
|
||||
if err := cfg.SetDesktopLanguage(tt.desktop); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
hub, err := assemble(ctx, io.Discard, io.Discard, shellIdentity{}, nil, newStartupPhases(io.Discard, time.Now))
|
||||
if err != nil {
|
||||
t.Fatalf("assemble: %v", err)
|
||||
}
|
||||
defer hub.Shutdown()
|
||||
b, err := bind(hub.Handler())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- b.serve(ctx) }()
|
||||
defer func() {
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
t.Error("host did not drain")
|
||||
}
|
||||
}()
|
||||
check := func(want string) {
|
||||
t.Helper()
|
||||
status, body := ask(t, b, http.MethodGet, "/tray/state", nil)
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("tray status = %d", status)
|
||||
}
|
||||
var state serve.TrayState
|
||||
if err := json.Unmarshal([]byte(body), &state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
say := i18n.Catalog(want)
|
||||
labels := serve.TrayLabels{Open: say.TrayOpen, CloseToTray: say.TrayCloseToTray, Quit: say.TrayQuit}
|
||||
if state.Labels != labels || state.Line != say.TrayIdle {
|
||||
t.Fatalf("tray = %+v, want labels %+v and line %q", state, labels, say.TrayIdle)
|
||||
}
|
||||
}
|
||||
check(tt.want)
|
||||
for _, lang := range []string{"en", "zh"} {
|
||||
status, _ := ask(t, b, http.MethodPost, "/appearance", func(r *http.Request) {
|
||||
r.Body = io.NopCloser(strings.NewReader(`{"language":"` + lang + `"}`))
|
||||
})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("appearance status = %d", status)
|
||||
}
|
||||
check(lang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -71,40 +71,3 @@ func TestTheHostPointsTheKernelsCatalogueAtTheConfiguredLanguage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sweeping a session directory costs a handful of file operations per
|
||||
// transcript, and the window cannot open until the handshake. A build that
|
||||
// sweeps inline makes the first launch after the cache went cold wait on
|
||||
// history it never displays.
|
||||
func TestTheHostsFirstBuildSweepsSessionsInTheBackground(t *testing.T) {
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse main.go: %v", err)
|
||||
}
|
||||
found := false
|
||||
ast.Inspect(file, func(n ast.Node) bool {
|
||||
lit, ok := n.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
sel, ok := lit.Type.(*ast.SelectorExpr)
|
||||
if !ok || sel.Sel.Name != "Options" {
|
||||
return true
|
||||
}
|
||||
for _, elt := range lit.Elts {
|
||||
kv, ok := elt.(*ast.KeyValueExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "CleanupPendingReconciler" {
|
||||
if v, ok := kv.Value.(*ast.SelectorExpr); ok && v.Sel.Name == "BackgroundCleanupReconciler" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if !found {
|
||||
t.Fatal("the host's boot.Options does not set CleanupPendingReconciler to serve.BackgroundCleanupReconciler")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -10,7 +11,10 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var cliChannelSecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
var cliChannelSecrets = []string{"STUDIO_NPM_TOKEN", "STUDIO_HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
// The 1.x line reads these repository secrets; studio must never touch them.
|
||||
var legacyRegistrySecrets = []string{"NPM_TOKEN", "HOMEBREW_TAP_TOKEN"}
|
||||
|
||||
func parseWorkflowFile(t *testing.T, path string) *yaml.Node {
|
||||
t.Helper()
|
||||
@@ -90,8 +94,8 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
if job == nil {
|
||||
t.Fatal("cli-channels job is missing")
|
||||
}
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Fatal("cli-channels must not declare an environment; the switch and the tag rule are its controls")
|
||||
if got := mappingScalar(job, "environment"); got != "studio-cli-channels" {
|
||||
t.Fatalf("cli-channels environment = %q, want studio-cli-channels", got)
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-channels if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
@@ -109,11 +113,18 @@ func TestCLIChannelsJobIsGatedAndAttested(t *testing.T) {
|
||||
t.Fatalf("cli-channels permission %s = %q, want %q", scope, got, level)
|
||||
}
|
||||
}
|
||||
if !nodeContains(job, "environments/studio-cli-channels") || !nodeContains(job, "required_reviewers") {
|
||||
t.Fatal("cli-channels must verify the environment's required reviewers before publishing")
|
||||
}
|
||||
assertGateBeforePublishing(t, job)
|
||||
for _, name := range cliChannelSecrets {
|
||||
if !secretRefPattern(name).MatchString(scalarString(job)) {
|
||||
t.Fatalf("cli-channels does not read secrets.%s", name)
|
||||
}
|
||||
}
|
||||
if name, found := refersTo(job, legacyRegistrySecrets); found {
|
||||
t.Fatalf("cli-channels reads %s; it must use the environment-only STUDIO_* names", name)
|
||||
}
|
||||
}
|
||||
|
||||
func scalarString(node *yaml.Node) string {
|
||||
@@ -122,20 +133,6 @@ func scalarString(node *yaml.Node) string {
|
||||
return strings.Join(scalars, "\n")
|
||||
}
|
||||
|
||||
func TestReleaseStudioGrantsNoWriteAll(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
if top := mappingValue(root, "permissions"); top != nil && top.Kind == yaml.ScalarNode && top.Value == "write-all" {
|
||||
t.Error("release-studio.yml grants write-all")
|
||||
}
|
||||
jobs := jobsOf(t, root)
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
name, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
if p := mappingValue(job, "permissions"); p != nil && p.Kind == yaml.ScalarNode && p.Value == "write-all" {
|
||||
t.Errorf("release-studio.yml job %s grants write-all", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoWorkflowGrantsIDTokenOrBroadPermissionsOutsideCLIChannels(t *testing.T) {
|
||||
files, err := filepath.Glob("../../.github/workflows/*.yml")
|
||||
if err != nil || len(files) == 0 {
|
||||
@@ -176,7 +173,7 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
if err != nil || len(files) == 0 {
|
||||
t.Fatalf("no workflows found: %v", err)
|
||||
}
|
||||
guarded := cliChannelSecrets
|
||||
guarded := append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...)
|
||||
for _, file := range files {
|
||||
root := parseWorkflowFile(t, file)
|
||||
jobs := mappingValue(root, "jobs")
|
||||
@@ -199,18 +196,18 @@ func TestOnlyCLIChannelsReferencesRegistrySecrets(t *testing.T) {
|
||||
continue
|
||||
}
|
||||
if name, found := refersTo(root, cliChannelSecrets); found {
|
||||
t.Errorf("%s reaches %s, which only release-studio.yml cli-channels may read", file, name)
|
||||
t.Errorf("%s reaches %s, which belongs to studio's cli-channels", file, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
for _, text := range []string{
|
||||
"${{ secrets.NPM_TOKEN }}",
|
||||
"${{ secrets['NPM_TOKEN'] }}",
|
||||
`${{ secrets["NPM_TOKEN"] }}`,
|
||||
"${{ secrets.STUDIO_NPM_TOKEN }}",
|
||||
"${{ secrets['STUDIO_NPM_TOKEN'] }}",
|
||||
`${{ secrets["STUDIO_NPM_TOKEN"] }}`,
|
||||
"${{ toJSON(secrets) }}",
|
||||
"${{ secrets[format('NPM_{0}', 'TOKEN')] }}",
|
||||
"${{ secrets[format('STUDIO_{0}', 'NPM_TOKEN')] }}",
|
||||
} {
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal([]byte("run: \""+regexp.MustCompile(`"`).ReplaceAllString(text, `\"`)+"\"\n"), &doc); err != nil {
|
||||
@@ -228,3 +225,82 @@ func TestSecretReferenceDetectionCoversBypassForms(t *testing.T) {
|
||||
t.Error("an unrelated secret was reported")
|
||||
}
|
||||
}
|
||||
|
||||
func assertGateBeforePublishing(t *testing.T, job *yaml.Node) {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
if steps == nil || steps.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-channels has no steps")
|
||||
}
|
||||
gate := -1
|
||||
for i, step := range steps.Content {
|
||||
if nodeContains(mappingValue(step, "run"), "environments/studio-cli-channels") {
|
||||
gate = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if gate < 0 {
|
||||
t.Fatal("cli-channels has no environment check step")
|
||||
}
|
||||
for i, step := range steps.Content[:gate] {
|
||||
if run := mappingScalar(step, "run"); run != "" || mappingValue(step, "env") != nil {
|
||||
t.Errorf("step %d runs or gets credentials before the environment check", i)
|
||||
}
|
||||
}
|
||||
for i, step := range steps.Content {
|
||||
if i > gate {
|
||||
continue
|
||||
}
|
||||
for _, name := range append(append([]string{}, cliChannelSecrets...), legacyRegistrySecrets...) {
|
||||
if nodeContains(step, name) {
|
||||
t.Errorf("step %d references %s before or at the environment check", i, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvironmentCheckStepFailsClosed(t *testing.T) {
|
||||
bash, err := exec.LookPath("bash")
|
||||
if err != nil {
|
||||
t.Skip("bash not available")
|
||||
}
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-channels")
|
||||
var script string
|
||||
for _, step := range mappingValue(job, "steps").Content {
|
||||
if run := mappingScalar(step, "run"); strings.Contains(run, "environments/studio-cli-channels") {
|
||||
script = run
|
||||
break
|
||||
}
|
||||
}
|
||||
if script == "" {
|
||||
t.Fatal("environment check step not found")
|
||||
}
|
||||
reviewers := `{"type":"required_reviewers","reviewers":[{"a":1}]}`
|
||||
cases := []struct {
|
||||
name, response string
|
||||
pass bool
|
||||
}{
|
||||
{"one reviewer", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[" + reviewers + "]}", true},
|
||||
{"no rules", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}", false},
|
||||
{"two JSON documents", "HTTP/2.0 200 OK\n\n{\"protection_rules\":[]}{\"protection_rules\":[]}", false},
|
||||
{"empty body", "HTTP/2.0 200 OK\n\n", false},
|
||||
{"non-numeric body", "HTTP/2.0 200 OK\n\n\"x\"", false},
|
||||
{"missing environment", "HTTP/2.0 404 Not Found\n\n{\"message\":\"Not Found\"}", false},
|
||||
{"server error", "HTTP/2.0 500 Error\n\n{}", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
dir := t.TempDir()
|
||||
fake := "#!/bin/sh\ncat <<'EOF'\n" + tc.response + "\nEOF\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "gh"), []byte(fake), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command(bash, "-c", script+"\necho PASSED")
|
||||
cmd.Env = append(os.Environ(), "PATH="+dir+string(os.PathListSeparator)+os.Getenv("PATH"), "REPO=x/y", "GH_TOKEN=x", "GITHUB_ACTOR=t")
|
||||
out, err := cmd.CombinedOutput()
|
||||
passed := err == nil && strings.Contains(string(out), "PASSED")
|
||||
if passed != tc.pass {
|
||||
t.Errorf("%s: passed=%v, want %v\n%s", tc.name, passed, tc.pass, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestCLIPointerJobIsSwitchedSerializedAndNarrow(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
job := mappingValue(jobsOf(t, root), "cli-pointer")
|
||||
if job == nil {
|
||||
t.Fatal("cli-pointer job is missing")
|
||||
}
|
||||
if got := mappingScalar(job, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-pointer if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
needs := mappingValue(job, "needs")
|
||||
if needs == nil || needs.Kind != yaml.SequenceNode {
|
||||
t.Fatal("cli-pointer must list its needs")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, n := range needs.Content {
|
||||
seen[n.Value] = true
|
||||
}
|
||||
if !seen["resolve"] || !seen["publish"] || !seen["cli-gate"] {
|
||||
t.Fatalf("cli-pointer needs %v, want resolve, publish and cli-gate", seen)
|
||||
}
|
||||
concurrency := mappingValue(job, "concurrency")
|
||||
if concurrency == nil || !strings.HasPrefix(mappingScalar(concurrency, "group"), "studio-cli-pointer-") {
|
||||
t.Fatal("cli-pointer must take its own per-channel lock, never the 1.x workflow's release-cli-<channel> group")
|
||||
}
|
||||
if mappingScalar(concurrency, "cancel-in-progress") != "false" {
|
||||
t.Fatal("cli-pointer must not cancel a run that is mid-write")
|
||||
}
|
||||
permissions := mappingValue(job, "permissions")
|
||||
if permissions == nil || permissions.Kind != yaml.MappingNode || len(permissions.Content) != 2 ||
|
||||
mappingScalar(permissions, "contents") != "write" {
|
||||
t.Fatal("cli-pointer permissions must be exactly contents: write")
|
||||
}
|
||||
if !nodeContains(job, "scripts/publish-cli-pointer.sh") {
|
||||
t.Fatal("cli-pointer must run scripts/publish-cli-pointer.sh")
|
||||
}
|
||||
if !nodeContains(job, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-pointer must pass vars.CLI_PUBLISH_FROZEN to its script")
|
||||
}
|
||||
if name, found := refersTo(job, append([]string{"RELEASE_TAG_TOKEN"}, cliChannelSecrets...)); found {
|
||||
t.Fatalf("cli-pointer reads %s; registry credentials belong to cli-channels only", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCLIGateBlocksEveryCLIChannel(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
gate := mappingValue(jobs, "cli-gate")
|
||||
if gate == nil {
|
||||
t.Fatal("cli-gate job is missing")
|
||||
}
|
||||
if got := mappingScalar(gate, "if"); got != "vars.STUDIO_PUBLISHES_CLI == 'true'" {
|
||||
t.Fatalf("cli-gate if = %q, want the STUDIO_PUBLISHES_CLI switch", got)
|
||||
}
|
||||
if !nodeContains(gate, "vars.CLI_PUBLISH_FROZEN") {
|
||||
t.Fatal("cli-gate must read vars.CLI_PUBLISH_FROZEN")
|
||||
}
|
||||
if nodeContains(gate, "actions/checkout") || nodeContains(gate, "secrets.") {
|
||||
t.Fatal("cli-gate must run no repository code and read no secret")
|
||||
}
|
||||
for _, name := range []string{"cli-tag", "cli-channels", "cli-pointer"} {
|
||||
needs := mappingValue(mappingValue(jobs, name), "needs")
|
||||
found := false
|
||||
for _, n := range needs.Content {
|
||||
found = found || n.Value == "cli-gate"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-gate", name)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"cli-channels", "cli-pointer"} {
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, name), "needs").Content {
|
||||
found = found || n.Value == "cli-tag"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("%s does not wait for cli-tag", name)
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, n := range mappingValue(mappingValue(jobs, "cli-channels"), "needs").Content {
|
||||
found = found || n.Value == "cli-pointer"
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("cli-channels does not wait for cli-pointer: the cask would name archives that may not exist yet")
|
||||
}
|
||||
}
|
||||
|
||||
var releaseTagSecrets = []string{"RELEASE_TAG_TOKEN"}
|
||||
|
||||
func workflowFiles(t *testing.T) []string {
|
||||
t.Helper()
|
||||
var files []string
|
||||
for _, pattern := range []string{"*.yml", "*.yaml"} {
|
||||
found, err := filepath.Glob("../../.github/workflows/" + pattern)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
files = append(files, found...)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
t.Fatal("no workflows found")
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// The token may appear in one inline step of Studio's cli-tag job and in no
|
||||
// other env scope: not the workflow's, not a job's, not another step's.
|
||||
func TestReleaseTagTokenIsReadByOneInlineStep(t *testing.T) {
|
||||
holders := 0
|
||||
for _, file := range workflowFiles(t) {
|
||||
root := parseWorkflowFile(t, file)
|
||||
studio := filepath.Base(file) == "release-studio.yml"
|
||||
for i := 0; i+1 < len(root.Content); i += 2 {
|
||||
if root.Content[i].Value == "jobs" {
|
||||
continue
|
||||
}
|
||||
if what, found := refersTo(root.Content[i+1], releaseTagSecrets); found {
|
||||
t.Errorf("%s reaches %s at workflow level (%s)", file, what, root.Content[i].Value)
|
||||
}
|
||||
}
|
||||
jobs := mappingValue(root, "jobs")
|
||||
if jobs == nil {
|
||||
continue
|
||||
}
|
||||
for i := 0; i+1 < len(jobs.Content); i += 2 {
|
||||
id, job := jobs.Content[i].Value, jobs.Content[i+1]
|
||||
allowed := studio && id == "cli-tag"
|
||||
for j := 0; j+1 < len(job.Content); j += 2 {
|
||||
key, value := job.Content[j].Value, job.Content[j+1]
|
||||
if key != "steps" {
|
||||
if what, found := refersTo(value, releaseTagSecrets); found {
|
||||
t.Errorf("%s job %s reaches %s in %s; only a step of cli-tag may", file, id, what, key)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, step := range value.Content {
|
||||
if what, found := refersTo(step, releaseTagSecrets); found {
|
||||
holders++
|
||||
if !allowed {
|
||||
t.Errorf("%s job %s reaches %s; only Studio's cli-tag step may", file, id, what)
|
||||
}
|
||||
if mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("%s job %s: the token step must be inline shell, not an action", file, id)
|
||||
}
|
||||
} else if allowed && mappingScalar(step, "uses") != "" {
|
||||
t.Errorf("cli-tag must check out nothing and run no action")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if holders != 1 {
|
||||
t.Fatalf("%d steps read RELEASE_TAG_TOKEN, want exactly one", holders)
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"gopkg.in/yaml.v3"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func stepNamed(t *testing.T, job *yaml.Node, name string) *yaml.Node {
|
||||
t.Helper()
|
||||
steps := mappingValue(job, "steps")
|
||||
for _, step := range steps.Content {
|
||||
if mappingScalar(step, "name") == name {
|
||||
return step
|
||||
}
|
||||
}
|
||||
t.Fatalf("no step %q", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestStudioReleaseDecidesLatestFromTheFlagsScript(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
step := stepNamed(t, publish, "Publish GitHub release")
|
||||
run := mappingScalar(step, "run")
|
||||
env := mappingValue(step, "env")
|
||||
if got := mappingScalar(env, "CANDIDATE"); got != "${{ needs.resolve.outputs.candidate }}" {
|
||||
t.Fatalf("CANDIDATE = %q, want the resolve job's grammar-derived output", got)
|
||||
}
|
||||
if !strings.Contains(run, `scripts/studio-release-flags.sh "$TAG" "$CANDIDATE"`) {
|
||||
t.Fatal("the release flags do not come from scripts/studio-release-flags.sh")
|
||||
}
|
||||
for _, flag := range []string{"--prerelease", "--latest"} {
|
||||
if strings.Contains(run, flag) {
|
||||
t.Errorf("the publish step spells %s itself; the decision belongs to the flags script", flag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(run, "gh release edit") && strings.Contains(run[strings.Index(run, "gh release edit"):], "--latest") {
|
||||
t.Error("an existing release must not have its latest flag edited")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStudioGitHubReleaseCarriesNoRootManifest(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
publish := mappingValue(jobsOf(t, root), "publish")
|
||||
run := mappingScalar(stepNamed(t, publish, "Publish GitHub release"), "run")
|
||||
if !strings.Contains(run, `= latest.json ]`) {
|
||||
t.Fatal("the publish step does not filter latest.json out of the uploaded assets")
|
||||
}
|
||||
for line := range strings.SplitSeq(run, "\n") {
|
||||
if strings.Contains(line, "gh release") && strings.Contains(line, "dist/") {
|
||||
t.Errorf("gh uploads the dist glob, root latest.json included: %s", strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
if !strings.Contains(run, `"${assets[@]}"`) {
|
||||
t.Fatal("the release is not created from the filtered asset list")
|
||||
}
|
||||
mirror := mappingScalar(stepNamed(t, publish, "Mirror to R2 and update the Studio catalog"), "run")
|
||||
if !strings.Contains(mirror, `aws s3 cp dist/ "s3://${R2_BUCKET}/${TAG}/" --recursive`) {
|
||||
t.Fatal("the per-tag manifest must still reach R2 with the rest of dist")
|
||||
}
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var windowsArchitectures = []string{"amd64", "arm64"}
|
||||
|
||||
func matrixArches(t *testing.T, job *yaml.Node) []string {
|
||||
t.Helper()
|
||||
matrix := mappingValue(mappingValue(job, "strategy"), "matrix")
|
||||
if matrix == nil {
|
||||
t.Fatal("job has no matrix")
|
||||
}
|
||||
var arches []string
|
||||
if list := mappingValue(matrix, "arch"); list != nil {
|
||||
for _, n := range list.Content {
|
||||
arches = append(arches, n.Value)
|
||||
}
|
||||
}
|
||||
if include := mappingValue(matrix, "include"); include != nil {
|
||||
for _, entry := range include.Content {
|
||||
if arch := mappingScalar(entry, "arch"); arch != "" {
|
||||
arches = append(arches, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(arches)
|
||||
return arches
|
||||
}
|
||||
|
||||
// One tree per job: each Windows stage runs once for every architecture the
|
||||
// build produces, so no architecture reaches publish without passing through
|
||||
// signing, packaging and verification.
|
||||
func TestEveryWindowsSigningStageCoversEveryArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-package", "windows-verify-package", "windows-sign-installer", "windows-install-smoke"} {
|
||||
if got := matrixArches(t, mappingValue(jobs, name)); !slices.Equal(got, windowsArchitectures) {
|
||||
t.Errorf("%s covers %v, want %v", name, got, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
build := mappingValue(mappingValue(mappingValue(mappingValue(jobs, "build"), "strategy"), "matrix"), "include")
|
||||
var built []string
|
||||
for _, entry := range build.Content {
|
||||
if goarch := mappingScalar(entry, "goarch"); goarch != "" {
|
||||
built = append(built, goarch)
|
||||
}
|
||||
}
|
||||
slices.Sort(built)
|
||||
if !slices.Equal(built, windowsArchitectures) {
|
||||
t.Errorf("build produces Windows bundles for %v, want %v", built, windowsArchitectures)
|
||||
}
|
||||
}
|
||||
|
||||
// Certum's group holds one running and one waiting job, and a newer waiting job
|
||||
// cancels the older. Legs of one matrix would race for that single slot, so the
|
||||
// signing stages run them strictly one after another.
|
||||
func TestWindowsSigningLegsRunOneAtATime(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-sign-payload", "windows-sign-installer"} {
|
||||
job := mappingValue(jobs, name)
|
||||
strategy := mappingValue(job, "strategy")
|
||||
if got := mappingScalar(strategy, "max-parallel"); got != "1" {
|
||||
t.Errorf("%s max-parallel = %q, want 1", name, got)
|
||||
}
|
||||
if got := mappingScalar(strategy, "fail-fast"); got != "false" {
|
||||
t.Errorf("%s fail-fast = %q; a failed leg must leave the others to finish so only it reruns", name, got)
|
||||
}
|
||||
group := mappingScalar(mappingValue(job, "concurrency"), "group")
|
||||
if group != "certum-signing" {
|
||||
t.Errorf("%s concurrency group = %q, want certum-signing", name, group)
|
||||
}
|
||||
if mappingScalar(job, "environment") != "studio-release" {
|
||||
t.Errorf("%s lost its studio-release environment", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Packaging, package verification and the install smoke parse archives and run
|
||||
// installers, so none of them may hold an environment or a secret.
|
||||
func TestWindowsPackagingStagesHoldNoSecrets(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
for _, name := range []string{"windows-package", "windows-verify-package", "windows-install-smoke"} {
|
||||
job := mappingValue(jobs, name)
|
||||
if mappingValue(job, "environment") != nil {
|
||||
t.Errorf("%s declares an environment", name)
|
||||
}
|
||||
if nodeContains(job, "secrets.") {
|
||||
t.Errorf("%s references a secret", name)
|
||||
}
|
||||
if nodeContains(job, "id-token") {
|
||||
t.Errorf("%s requests an id-token", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The arm64 installer reads back through the runner's 7-Zip, which decodes
|
||||
// filters the NSIS extractor cannot, so the filter that extractor reads is
|
||||
// pinned on every job that packs one, and publish waits for an installer to run.
|
||||
func TestArm64InstallerIsPackedWithTheFilterTheExtractorReads(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
pack := map[string]string{"build": "Package Windows from the bundle", "windows-package": "Package Windows from the signed bundle"}
|
||||
for job, step := range pack {
|
||||
env := mappingValue(stepNamed(t, mappingValue(jobs, job), step), "env")
|
||||
if got := mappingScalar(env, "ELECTRON_BUILDER_7Z_FILTER"); !strings.Contains(got, "'BCJ'") {
|
||||
t.Errorf("%s / %s: ELECTRON_BUILDER_7Z_FILTER = %q, want BCJ for arm64", job, step, got)
|
||||
}
|
||||
}
|
||||
needs := mappingValue(mappingValue(jobs, "publish"), "needs")
|
||||
var names []string
|
||||
for _, n := range needs.Content {
|
||||
names = append(names, n.Value)
|
||||
}
|
||||
if !slices.Contains(names, "windows-install-smoke") {
|
||||
t.Error("publish does not wait for windows-install-smoke")
|
||||
}
|
||||
if !strings.Contains(mappingScalar(mappingValue(jobs, "publish"), "if"), "needs.windows-install-smoke.result == 'success'") {
|
||||
t.Error("publish does not require windows-install-smoke to have succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
// Matrix legs share one output map and the last non-empty write wins, so each
|
||||
// leg writes only its own architecture's key. A key that is not guarded by the
|
||||
// leg's architecture would let one leg's value stand in for the other's.
|
||||
func TestWindowsOutputsAreOnePerArchitecture(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
want := map[string][]string{
|
||||
"windows-sign-payload": {"payload-digest"},
|
||||
"windows-verify-package": {"installer-sha256", "archive-sha256"},
|
||||
"windows-sign-installer": {"installer-sha256"},
|
||||
}
|
||||
for name, keys := range want {
|
||||
outputs := mappingValue(mappingValue(jobs, name), "outputs")
|
||||
if outputs == nil {
|
||||
t.Fatalf("%s has no outputs", name)
|
||||
}
|
||||
if len(outputs.Content)/2 != len(keys)*len(windowsArchitectures) {
|
||||
t.Errorf("%s declares %d outputs, want %d", name, len(outputs.Content)/2, len(keys)*len(windowsArchitectures))
|
||||
}
|
||||
for _, key := range keys {
|
||||
for _, arch := range windowsArchitectures {
|
||||
got := mappingScalar(outputs, key+"-"+arch)
|
||||
if !strings.Contains(got, "matrix.arch == '"+arch+"'") {
|
||||
t.Errorf("%s output %s-%s = %q, want it guarded by matrix.arch == '%s'", name, key, arch, got, arch)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The install smoke gates publish, so it has to run exactly when a Windows
|
||||
// package exists to run: after a signed installer, or after the build when
|
||||
// signing is off, and never after a failed signing chain.
|
||||
func TestInstallSmokeRunsOnlyWhereAPackageExists(t *testing.T) {
|
||||
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
|
||||
jobs := jobsOf(t, root)
|
||||
smoke := mappingValue(jobs, "windows-install-smoke")
|
||||
cond := mappingScalar(smoke, "if")
|
||||
for _, want := range []string{
|
||||
"!cancelled()",
|
||||
"needs.build.result == 'success'",
|
||||
"needs.windows-sign-installer.result == 'success'",
|
||||
"vars.STUDIO_SIGNING_ENABLED != 'true'",
|
||||
"needs.windows-sign-installer.result == 'skipped'",
|
||||
} {
|
||||
if !strings.Contains(cond, want) {
|
||||
t.Errorf("windows-install-smoke if lacks %q: %s", want, cond)
|
||||
}
|
||||
}
|
||||
run := mappingScalar(stepNamed(t, smoke, "Install, check and uninstall"), "run")
|
||||
if !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the smoke job does not run scripts/install-smoke.ps1, the script pull requests run")
|
||||
}
|
||||
studio := parseWorkflowFile(t, "../../.github/workflows/studio.yml")
|
||||
pkg := mappingValue(jobsOf(t, studio), "package")
|
||||
if run := mappingScalar(stepNamed(t, pkg, "The package installs, runs and uninstalls"), "run"); !strings.Contains(run, "./scripts/install-smoke.ps1") {
|
||||
t.Error("the Studio package job does not run scripts/install-smoke.ps1, so pull requests would not exercise the release smoke")
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if !strings.Contains(run, "'"+name+"'") {
|
||||
t.Errorf("publish does not require %s by name", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -189,46 +189,3 @@ func TestUnmirroredManifestNamesOnlyGitHub(t *testing.T) {
|
||||
t.Fatalf("asset = %+v, want GitHub alone when nothing is mirrored", a)
|
||||
}
|
||||
}
|
||||
|
||||
// Both Windows architectures ship an installer and a portable archive under
|
||||
// the same naming rule; each installer answers only its own platform key.
|
||||
func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
|
||||
for _, name := range []string{
|
||||
"ReasonixStudio-windows-amd64-installer.exe",
|
||||
"ReasonixStudio-windows-amd64.zip",
|
||||
"ReasonixStudio-windows-arm64-installer.exe",
|
||||
"ReasonixStudio-windows-arm64.zip",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", false); 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)
|
||||
}
|
||||
if len(m.Platforms) != 2 || len(m.Downloads) != 2 {
|
||||
t.Fatalf("platforms %d, downloads %d; want one installer per architecture", len(m.Platforms), len(m.Downloads))
|
||||
}
|
||||
for arch, name := range map[string]string{
|
||||
"amd64": "ReasonixStudio-windows-amd64-installer.exe",
|
||||
"arm64": "ReasonixStudio-windows-arm64-installer.exe",
|
||||
} {
|
||||
a, ok := m.Platforms[update.PlatformKey("windows", arch)]
|
||||
if !ok || !strings.HasSuffix(a.URL, "/"+name) {
|
||||
t.Errorf("windows-%s resolves to %+v, want %s", arch, a, name)
|
||||
}
|
||||
}
|
||||
if len(m.Deltas) != 0 {
|
||||
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 25 KiB |
@@ -99,17 +99,6 @@
|
||||
# full of someone's work gets named in the first place. Refuse with an exit
|
||||
# code, because that is all a script driving this can read.
|
||||
!macro customInit
|
||||
# The arm64 installer carries only an arm64 application: on any other
|
||||
# machine electron-builder's extraction picks no archive and installs nothing
|
||||
# while reporting success.
|
||||
!ifdef APP_ARM64
|
||||
${IfNot} ${IsNativeARM64}
|
||||
DetailPrint "This installer is for Windows on ARM64 and cannot run on this machine"
|
||||
MessageBox MB_ICONSTOP "This Reasonix Studio installer is built for Windows on ARM64.$\r$\n$\r$\nDownload the x64 installer for this machine." /SD IDOK
|
||||
SetErrorLevel 3
|
||||
Abort
|
||||
${EndIf}
|
||||
!endif
|
||||
Push $R9
|
||||
!insertmacro instDirTakesNothingElse $R9
|
||||
${If} $R9 == 0
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
@@ -19,7 +19,7 @@
|
||||
"computer-live": "electron test/computer_live.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "44.5.1",
|
||||
"electron": "44.4.5",
|
||||
"electron-builder": "^26.15.3",
|
||||
"playwright": "1.63.0"
|
||||
},
|
||||
|
||||
@@ -45,35 +45,22 @@ function go(args, env) {
|
||||
// statically so the helper needs no redistributable on the person's machine.
|
||||
const VS_INSTALLER = path.join(process.env["ProgramFiles(x86)"] || "C:\\Program Files (x86)", "Microsoft Visual Studio", "Installer");
|
||||
|
||||
// The helper is built for the architecture the kernel is, with that
|
||||
// architecture's own tools: the x64 toolset on an x64 machine, the ARM64 one on
|
||||
// an ARM64 machine (vcvarsarm64 is the native environment there).
|
||||
const WINDOWS_TOOLCHAIN = {
|
||||
amd64: { component: "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", vars: "vcvars64.bat", label: "x64" },
|
||||
arm64: { component: "Microsoft.VisualStudio.Component.VC.Tools.ARM64", vars: "vcvarsarm64.bat", label: "ARM64" },
|
||||
};
|
||||
|
||||
function msvcVars(toolchain) {
|
||||
function msvcVars() {
|
||||
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
|
||||
if (!fs.existsSync(vswhere)) return "";
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" });
|
||||
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : "";
|
||||
return vars && fs.existsSync(vars) ? vars : "";
|
||||
}
|
||||
|
||||
function buildWindowsHelper(goarch) {
|
||||
const toolchain = WINDOWS_TOOLCHAIN[goarch];
|
||||
if (!toolchain) {
|
||||
console.error(`no MSVC toolchain is declared for windows/${goarch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const vars = msvcVars(toolchain);
|
||||
function buildWindowsHelper() {
|
||||
const vars = msvcVars();
|
||||
if (!vars) {
|
||||
// A release without the helper would ship computer use switched off with
|
||||
// nothing saying why, so CI refuses; a dev build only loses the feature.
|
||||
if (process.env.CI) {
|
||||
console.error(`MSVC with the ${toolchain.label} C++ tools is required to build reasonix-computer-helper.exe`);
|
||||
console.error("MSVC with the x64 C++ tools is required to build reasonix-computer-helper.exe");
|
||||
process.exit(1);
|
||||
}
|
||||
console.warn("MSVC not found: building without reasonix-computer-helper.exe, so computer use is unavailable in this build");
|
||||
@@ -104,12 +91,9 @@ function buildWindowsHelper(goarch) {
|
||||
if (process.platform !== "darwin") {
|
||||
// go build names the binary after the package and adds .exe where it belongs.
|
||||
// Without cgo, because with it the Linux kernel inherits the runner's glibc.
|
||||
// REASONIX_GOARCH names the architecture to build for; unset, Go's own is
|
||||
// used, which is the runner's.
|
||||
const goarch = (process.env.REASONIX_GOARCH || "").trim();
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], goarch ? { CGO_ENABLED: "0", GOARCH: goarch } : { CGO_ENABLED: "0" });
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" });
|
||||
console.log("built reasonix-studio-host");
|
||||
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
|
||||
if (process.platform === "win32") buildWindowsHelper();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,17 +7,11 @@ const { chromium } = require("playwright");
|
||||
|
||||
const ASSETS = path.join(__dirname, "..", "assets");
|
||||
const ICO_SIZES = [16, 20, 24, 32, 40, 48, 64, 256];
|
||||
// The tray is drawn at 16 logical pixels. Windows asks for exactly 16*scale
|
||||
// pixels and resizes whatever it is handed, so every Windows scale has its own
|
||||
// file (src/trayimage.js picks it).
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [1.75, "@1.75x"], [2, "@2x"], [2.5, "@2.5x"], [3, "@3x"]];
|
||||
// The tray is drawn at 16 logical pixels; nativeImage picks the @Nx file that
|
||||
// matches the display, so a scaled screen never enlarges the 16px one.
|
||||
const TRAY_SCALES = [[1, ""], [1.25, "@1.25x"], [1.5, "@1.5x"], [2, "@2x"], [3, "@3x"]];
|
||||
// The app icon's margin is the platform's to keep; a tray slot has none to spare.
|
||||
const TRAY_VIEWBOX = 'viewBox="103 103 818 818"';
|
||||
// Windows draws the .ico edge to edge beside neighbours that fill their cell, so
|
||||
// its tile is cropped to a 2% margin and the glyph enlarged within it; macOS and
|
||||
// Linux keep the full-canvas artwork the platform templates expect.
|
||||
const WIN_VIEWBOX = 'viewBox="91 91 842 842"';
|
||||
const WIN_GLYPH_SCALE = "scale(2.4)";
|
||||
// PNG payloads for every type modern macOS reads; ic11–ic14 are the @2x slots.
|
||||
const ICNS_TYPES = [
|
||||
["ic04", 16], ["ic05", 32], ["ic07", 128], ["ic08", 256], ["ic09", 512], ["ic10", 1024],
|
||||
@@ -79,9 +73,8 @@ async function main() {
|
||||
for (const [scale, suffix] of TRAY_SCALES) {
|
||||
fs.writeFileSync(path.join(ASSETS, `tray${suffix}.png`), await render(page, traySvg, 16 * scale));
|
||||
}
|
||||
const winSvg = svg.replace('viewBox="0 0 1024 1024"', WIN_VIEWBOX).replace("scale(1.9)", WIN_GLYPH_SCALE);
|
||||
const icoPngs = [];
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await render(page, winSvg, size)]);
|
||||
for (const size of ICO_SIZES) icoPngs.push([size, await at(size)]);
|
||||
fs.writeFileSync(path.join(ASSETS, "icon.ico"), ico(icoPngs));
|
||||
const icnsPngs = [];
|
||||
for (const [type, size] of ICNS_TYPES) icnsPngs.push([type, await at(size)]);
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
"use strict";
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const MACHINE = { amd64: 0x8664, arm64: 0xaa64 };
|
||||
// Written by the NSIS installer rather than built by us, and 32-bit on every
|
||||
// architecture; the release pins it by content (windows-signing-lib.ps1).
|
||||
const INSTALLER_OWNED = { "resources/elevate.exe": 0x14c, "Uninstall Reasonix Studio.exe": 0x14c };
|
||||
const EXTENSIONS = new Set([".exe", ".dll", ".node"]);
|
||||
|
||||
// The machine field of a PE image's COFF header. An emulated x64 binary and a
|
||||
// native one are both valid programs on ARM64 Windows, so the architecture a
|
||||
// package claims has to be read from the files themselves.
|
||||
function peMachine(bytes) {
|
||||
if (bytes.length < 0x40 || bytes.readUInt16LE(0) !== 0x5a4d) throw new Error("not a PE image");
|
||||
const header = bytes.readUInt32LE(0x3c);
|
||||
if (header + 6 > bytes.length || bytes.readUInt32LE(header) !== 0x00004550) throw new Error("no PE signature");
|
||||
return bytes.readUInt16LE(header + 4);
|
||||
}
|
||||
|
||||
function isPeCandidate(file) {
|
||||
if (EXTENSIONS.has(path.extname(file).toLowerCase())) return true;
|
||||
const fd = fs.openSync(file, "r");
|
||||
try {
|
||||
const head = Buffer.alloc(2);
|
||||
return fs.readSync(fd, head, 0, 2, 0) === 2 && head.readUInt16LE(0) === 0x5a4d;
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
}
|
||||
|
||||
function walk(dir, into) {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full, into);
|
||||
else if (entry.isFile() && isPeCandidate(full)) into.push(full);
|
||||
}
|
||||
return into;
|
||||
}
|
||||
|
||||
// Every PE image under root with the machine it was built for.
|
||||
function machines(root) {
|
||||
return walk(root, []).sort().map((file) => {
|
||||
const name = path.relative(root, file).split(path.sep).join("/");
|
||||
try {
|
||||
return { file: name, machine: peMachine(fs.readFileSync(file)) };
|
||||
} catch (err) {
|
||||
return { file: name, machine: null, error: err.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// The images under root that are not built for arch; none means the tree is
|
||||
// uniformly that architecture. An empty tree is refused, not passed.
|
||||
function foreign(root, arch) {
|
||||
const want = MACHINE[arch];
|
||||
if (want === undefined) throw new Error(`unknown architecture ${arch}`);
|
||||
const found = machines(root);
|
||||
if (found.length === 0) throw new Error(`no PE images under ${root}`);
|
||||
return found.filter((f) => f.machine !== (INSTALLER_OWNED[f.file] ?? want));
|
||||
}
|
||||
|
||||
module.exports = { peMachine, machines, foreign, MACHINE };
|
||||
|
||||
if (require.main === module) {
|
||||
const [root, arch] = process.argv.slice(2);
|
||||
if (!root || !arch) {
|
||||
console.error("usage: node packaging/pe.js <dir> <amd64|arm64>");
|
||||
process.exit(2);
|
||||
}
|
||||
for (const f of machines(root)) console.log(`${f.machine === null ? f.error : "0x" + f.machine.toString(16)} ${f.file}`);
|
||||
const wrong = foreign(root, arch);
|
||||
for (const f of wrong) {
|
||||
console.error(f.error ? `::error::${f.file} cannot be read as a PE image: ${f.error}` : `::error::${f.file} is machine 0x${f.machine.toString(16)}, not ${arch}`);
|
||||
}
|
||||
process.exit(wrong.length === 0 ? 0 : 1);
|
||||
}
|
||||
Generated
+5
-5
@@ -9,8 +9,8 @@ importers:
|
||||
.:
|
||||
devDependencies:
|
||||
electron:
|
||||
specifier: 44.5.1
|
||||
version: 44.5.1([email protected])
|
||||
specifier: 44.4.5
|
||||
version: 44.4.5([email protected])
|
||||
electron-builder:
|
||||
specifier: ^26.15.3
|
||||
version: 26.15.3([email protected])([email protected])
|
||||
@@ -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.4.5:
|
||||
resolution: {integrity: sha512-SjgoaeYsSWZfJzubgQU7juvuXMTvn6/e1gAHdGFA/yuMbpF+I+skYqIJ6DdXBXHeWbkbpNpmwZCTpiivtlWZSw==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -1551,7 +1551,7 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@44.5.1([email protected]):
|
||||
electron@44.4.5([email protected]):
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.5
|
||||
'@electron/get': 5.1.0([email protected])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"use strict";
|
||||
const { localPath, trimTyped } = require("./localpath");
|
||||
|
||||
// What a page in the agent's browser may become. The agent's own requests pass
|
||||
// the kernel's policy first; this is the window's answer for everything else a
|
||||
@@ -17,16 +16,13 @@ function guestNavigationAllowed(raw, kernelOrigin) {
|
||||
}
|
||||
|
||||
// What the person typed, as the address to load and the one to fall back to.
|
||||
// A path on disk is the file it names; see localPath. Only "scheme://", file:
|
||||
// or about: names a scheme: "intranet:8080" is a host and a
|
||||
// Only "scheme://" or about: names a scheme: "intranet:8080" is a host and a
|
||||
// port. A host that cannot be on the public internet is read as http, the way
|
||||
// Chromium exempts it from upgrading; any other bare host tries https first
|
||||
// and falls back to http, since an intranet site may serve only http.
|
||||
function typedAddress(raw) {
|
||||
const text = trimTyped(raw);
|
||||
const text = String(raw || "").trim();
|
||||
if (!text) return { url: "", fallback: "" };
|
||||
const local = localPath(text);
|
||||
if (local) return { url: local.url, fallback: "" };
|
||||
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(text) || /^about:/i.test(text)) return { url: text, fallback: "" };
|
||||
let host;
|
||||
try {
|
||||
@@ -38,24 +34,6 @@ function typedAddress(raw) {
|
||||
return { url: "https://" + text, fallback: "http://" + text };
|
||||
}
|
||||
|
||||
// typed is the whole answer for what the person typed: the address, its
|
||||
// fallback, and "" or why the window will not load it. A page's own links never
|
||||
// reach a file (guestNavigationAllowed); the person may, but only on this
|
||||
// machine: a share is refused, and Windows would sign in to its host. The
|
||||
// kernel still refuses to read or act on a page outside the workspace, whoever
|
||||
// opened it.
|
||||
function typed(raw, kernelOrigin) {
|
||||
const local = localPath(raw);
|
||||
if (local?.kind === "network") return { url: "", fallback: "", refusal: "network_file" };
|
||||
if (local?.kind === "invalid") return { url: "", fallback: "", refusal: "scheme" };
|
||||
const { url, fallback } = typedAddress(raw);
|
||||
return { url, fallback, refusal: url.startsWith("file:") ? "" : webRefusal(url, kernelOrigin) };
|
||||
}
|
||||
|
||||
function webRefusal(url, kernelOrigin) {
|
||||
return guestNavigationAllowed(url, kernelOrigin) ? "" : "scheme";
|
||||
}
|
||||
|
||||
// privateHost reads the address's structure, never its spelling: a loopback,
|
||||
// private, link-local or shared IP literal, localhost, or a name with no dot,
|
||||
// which no public resolver answers for.
|
||||
@@ -80,4 +58,4 @@ function privateHost(host) {
|
||||
return h === "localhost" || h.endsWith(".localhost") || !h.includes(".");
|
||||
}
|
||||
|
||||
module.exports = { guestNavigationAllowed, typedAddress, typed, privateHost };
|
||||
module.exports = { guestNavigationAllowed, typedAddress, privateHost };
|
||||
@@ -1,8 +1,7 @@
|
||||
"use strict";
|
||||
const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typed } = require("./browserguard");
|
||||
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
|
||||
|
||||
// The size a page lays out at while nobody is looking at it: the kernel drives
|
||||
// pages whether or not the panel is open, and a page sized to nothing reads as
|
||||
@@ -97,7 +96,6 @@ class BrowserViews {
|
||||
if (allowed(to)) onPopup(to);
|
||||
return { action: "deny" };
|
||||
});
|
||||
installPaneReload(contents);
|
||||
contents.debugger.attach("1.3");
|
||||
contents.debugger.on("message", (_event, method, params, sessionId) => {
|
||||
if (sessionId) return;
|
||||
@@ -221,15 +219,12 @@ class BrowserViews {
|
||||
}
|
||||
}
|
||||
|
||||
// navigate starts loading what the person typed and answers "", or the code
|
||||
// of why the window will not: "scheme" or "network_file".
|
||||
navigate(targetId, address) {
|
||||
const entry = this.entries.get(targetId);
|
||||
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
|
||||
if (!entry) return "scheme";
|
||||
if (refusal) return refusal;
|
||||
const { url, fallback } = typedAddress(address);
|
||||
if (!entry || !guestNavigationAllowed(url, this.kernelOrigin)) return false;
|
||||
void this.loadTyped(targetId, entry, url, fallback);
|
||||
return "";
|
||||
return true;
|
||||
}
|
||||
|
||||
// loadTyped owns the outcome of an address the person typed. loadURL's own
|
||||
|
||||
@@ -1,131 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
// Role labels come from the platform's own catalogue, which follows the
|
||||
// system locale rather than the language the interface was set to, so they are
|
||||
// stated here for the two languages the interface draws.
|
||||
const WORDS = {
|
||||
zh: {
|
||||
edit: "编辑",
|
||||
undo: "撤销",
|
||||
redo: "重做",
|
||||
cut: "剪切",
|
||||
copy: "复制",
|
||||
paste: "粘贴",
|
||||
selectAll: "全选",
|
||||
pasteAndMatchStyle: "粘贴并匹配样式",
|
||||
delete: "删除",
|
||||
substitutions: "替换",
|
||||
showSubstitutions: "显示替换",
|
||||
smartQuotes: "智能引号",
|
||||
smartDashes: "智能破折号",
|
||||
textReplacement: "文本替换",
|
||||
speech: "语音",
|
||||
startSpeaking: "开始朗读",
|
||||
stopSpeaking: "停止朗读",
|
||||
},
|
||||
en: {
|
||||
edit: "Edit",
|
||||
undo: "Undo",
|
||||
redo: "Redo",
|
||||
cut: "Cut",
|
||||
copy: "Copy",
|
||||
paste: "Paste",
|
||||
selectAll: "Select All",
|
||||
pasteAndMatchStyle: "Paste and Match Style",
|
||||
delete: "Delete",
|
||||
substitutions: "Substitutions",
|
||||
showSubstitutions: "Show Substitutions",
|
||||
smartQuotes: "Smart Quotes",
|
||||
smartDashes: "Smart Dashes",
|
||||
textReplacement: "Text Replacement",
|
||||
speech: "Speech",
|
||||
startSpeaking: "Start Speaking",
|
||||
stopSpeaking: "Stop Speaking",
|
||||
},
|
||||
};
|
||||
|
||||
function wordsFor(lang) {
|
||||
return WORDS[lang] ?? WORDS.en;
|
||||
}
|
||||
|
||||
// The edit commands as roles carrying explicit labels: roles do the clipboard
|
||||
// work, the labels follow the interface language.
|
||||
function editItems(lang, can) {
|
||||
const w = wordsFor(lang);
|
||||
const on = (flag) => (can ? { enabled: !!can[flag] } : {});
|
||||
return [
|
||||
{ role: "undo", label: w.undo, ...on("canUndo") },
|
||||
{ role: "redo", label: w.redo, ...on("canRedo") },
|
||||
{ type: "separator" },
|
||||
{ role: "cut", label: w.cut, ...on("canCut") },
|
||||
{ role: "copy", label: w.copy, ...on("canCopy") },
|
||||
{ role: "paste", label: w.paste, ...on("canPaste") },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll", label: w.selectAll, ...on("canSelectAll") },
|
||||
];
|
||||
}
|
||||
|
||||
// The context menu's shape, kept apart from the platform so it can be checked
|
||||
// without one. editFlags is the page's own account of what is possible at the
|
||||
// click: this only decides what to offer.
|
||||
function contextTemplate(params, lang) {
|
||||
// click: roles do the clipboard work, this only decides what to offer.
|
||||
function contextTemplate(params) {
|
||||
if (!params.isEditable && !params.selectionText) return [];
|
||||
return editItems(lang, params.editFlags ?? {});
|
||||
}
|
||||
|
||||
// Everything the platform's own Edit menu carries, with the same roles, so
|
||||
// only the words change.
|
||||
function editMenuTemplate(lang) {
|
||||
const w = wordsFor(lang);
|
||||
const items = editItems(lang);
|
||||
const at = items.findIndex((i) => i.role === "selectAll");
|
||||
items.splice(at - 1, 1);
|
||||
items.splice(at - 1, 0,
|
||||
{ role: "pasteAndMatchStyle", label: w.pasteAndMatchStyle },
|
||||
{ role: "delete", label: w.delete });
|
||||
items.push(
|
||||
const can = params.editFlags ?? {};
|
||||
return [
|
||||
{ role: "undo", enabled: !!can.canUndo },
|
||||
{ role: "redo", enabled: !!can.canRedo },
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: w.substitutions,
|
||||
submenu: [
|
||||
{ role: "showSubstitutions", label: w.showSubstitutions },
|
||||
{ type: "separator" },
|
||||
{ role: "toggleSmartQuotes", label: w.smartQuotes },
|
||||
{ role: "toggleSmartDashes", label: w.smartDashes },
|
||||
{ role: "toggleTextReplacement", label: w.textReplacement },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: w.speech,
|
||||
submenu: [
|
||||
{ role: "startSpeaking", label: w.startSpeaking },
|
||||
{ role: "stopSpeaking", label: w.stopSpeaking },
|
||||
],
|
||||
},
|
||||
);
|
||||
return { label: w.edit, submenu: items };
|
||||
{ role: "cut", enabled: !!can.canCut },
|
||||
{ role: "copy", enabled: !!can.canCopy },
|
||||
{ role: "paste", enabled: !!can.canPaste },
|
||||
{ type: "separator" },
|
||||
{ role: "selectAll", enabled: !!can.canSelectAll },
|
||||
];
|
||||
}
|
||||
|
||||
function applicationMenuTemplate(lang) {
|
||||
return [{ role: "appMenu" }, editMenuTemplate(lang), { role: "windowMenu" }];
|
||||
}
|
||||
|
||||
// Only the Edit menu follows the interface language; the application and window
|
||||
// menus are roles and follow the OS. Menu is injected so this runs without Electron.
|
||||
function menuInstaller(Menu, platform = process.platform) {
|
||||
let installedLang = null;
|
||||
return function installApplicationMenu(languageOf = () => "en") {
|
||||
if (platform !== "darwin") {
|
||||
Menu.setApplicationMenu(null);
|
||||
return null;
|
||||
}
|
||||
const lang = languageOf();
|
||||
if (lang === installedLang) return Menu.getApplicationMenu();
|
||||
installedLang = lang;
|
||||
const menu = Menu.buildFromTemplate(applicationMenuTemplate(lang));
|
||||
Menu.setApplicationMenu(menu);
|
||||
return menu;
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { contextTemplate, editMenuTemplate, applicationMenuTemplate, menuInstaller };
|
||||
module.exports = { contextTemplate };
|
||||
@@ -1,15 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
// The page's own `--page` ground for each scheme (frontend-next tokens.css),
|
||||
// as sRGB. A window paints this wherever the page does not, so a pixel the
|
||||
// compositor leaves uncovered at a fractional scale matches the app.
|
||||
const GROUND = { light: "#f4f1ea", dark: "#060a10" };
|
||||
|
||||
// "auto" and anything unrecognised follow the system, as the page does.
|
||||
function groundFor(prefs, systemDark) {
|
||||
const theme = prefs?.["rx-theme"];
|
||||
const dark = theme === "dark" ? true : theme === "light" ? false : systemDark;
|
||||
return dark ? GROUND.dark : GROUND.light;
|
||||
}
|
||||
|
||||
module.exports = { GROUND, groundFor };
|
||||
@@ -5,42 +5,22 @@ const PROTOCOL_VERSION = 1;
|
||||
// A child that never sends a handshake must not be able to grow this process
|
||||
// while it waits, nor hold the launch open forever.
|
||||
const HANDSHAKE_LIMIT = 4096;
|
||||
const HANDSHAKE_TIMEOUT_MS = 60000;
|
||||
const SLOW_AFTER_MS = 3000;
|
||||
|
||||
// HostStartError says why a launch has no kernel. code is one of "timeout",
|
||||
// "exited", "spawn", "overflow" or "malformed"; callers branch on it, never on
|
||||
// the sentence.
|
||||
class HostStartError extends Error {
|
||||
constructor(code, message, detail = {}) {
|
||||
super(message);
|
||||
this.name = "HostStartError";
|
||||
this.code = code;
|
||||
Object.assign(this, detail);
|
||||
}
|
||||
}
|
||||
const HANDSHAKE_TIMEOUT_MS = 20000;
|
||||
|
||||
// start spawns the kernel and reads the handshake it writes to stdout. Every
|
||||
// line after it is an act a handover asks of this process, because that pipe is
|
||||
// the only channel pointing this way. stdin is the lease: the kernel drains
|
||||
// when this end closes, which is what stops it outliving a parent that exited
|
||||
// without being asked to.
|
||||
function start(binary, args, { onStderr, onExit, onAct, onSlow, systemLanguage, timeoutMs = HANDSHAKE_TIMEOUT_MS } = {}) {
|
||||
const env = { ...process.env };
|
||||
if (systemLanguage) env.REASONIX_SYSTEM_LANG = systemLanguage;
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"], env });
|
||||
function start(binary, args, { onStderr, onExit, onAct } = {}) {
|
||||
const child = spawn(binary, args, { stdio: ["pipe", "pipe", "pipe"] });
|
||||
child.stderr.setEncoding("utf8");
|
||||
if (onStderr) child.stderr.on("data", onStderr);
|
||||
if (onExit) child.on("exit", onExit);
|
||||
const ready = firstLine(child, { onSlow, timeoutMs }).then(({ line, rest }) => {
|
||||
const ready = firstLine(child).then(({ line, rest }) => {
|
||||
// Only once the handshake parses: a child whose first line was not one has
|
||||
// said nothing this process should act on.
|
||||
let handshake;
|
||||
try {
|
||||
handshake = parse(line);
|
||||
} catch (err) {
|
||||
throw new HostStartError("malformed", err.message);
|
||||
}
|
||||
const handshake = parse(line);
|
||||
if (onAct) readActs(child, rest, onAct);
|
||||
return handshake;
|
||||
});
|
||||
@@ -80,31 +60,19 @@ function readActs(child, rest, onAct) {
|
||||
drain();
|
||||
}
|
||||
|
||||
function firstLine(child, { onSlow, timeoutMs }) {
|
||||
function firstLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffered = "";
|
||||
const began = Date.now();
|
||||
const elapsed = () => Date.now() - began;
|
||||
const settle = (fn, value) => {
|
||||
clearTimeout(timer);
|
||||
clearTimeout(slow);
|
||||
child.stdout.off("data", onData);
|
||||
child.off("exit", onEarlyExit);
|
||||
child.off("error", onSpawnFailure);
|
||||
fn(value);
|
||||
};
|
||||
const slow = onSlow ? setTimeout(onSlow, Math.min(SLOW_AFTER_MS, timeoutMs)) : undefined;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
settle(
|
||||
reject,
|
||||
new HostStartError(
|
||||
"timeout",
|
||||
`the kernel was still running but sent no handshake within ${Math.round(timeoutMs / 1000)} s`,
|
||||
{ elapsedMs: elapsed() },
|
||||
),
|
||||
),
|
||||
timeoutMs,
|
||||
() => settle(reject, new Error("the kernel sent no handshake")),
|
||||
HANDSHAKE_TIMEOUT_MS,
|
||||
);
|
||||
const onData = (chunk) => {
|
||||
buffered += chunk;
|
||||
@@ -113,22 +81,15 @@ function firstLine(child, { onSlow, timeoutMs }) {
|
||||
return settle(resolve, { line: buffered.slice(0, end), rest: buffered.slice(end + 1) });
|
||||
}
|
||||
if (buffered.length > HANDSHAKE_LIMIT) {
|
||||
settle(reject, new HostStartError("overflow", "the handshake ran past its limit"));
|
||||
settle(reject, new Error("the handshake ran past its limit"));
|
||||
}
|
||||
};
|
||||
const onEarlyExit = (code, signal) =>
|
||||
settle(
|
||||
reject,
|
||||
new HostStartError("exited", `the kernel exited with ${code ?? `signal ${signal}`} before saying anything`, {
|
||||
exitCode: code,
|
||||
signal,
|
||||
elapsedMs: elapsed(),
|
||||
}),
|
||||
);
|
||||
const onEarlyExit = (code) =>
|
||||
settle(reject, new Error(`the kernel exited with ${code} before saying anything`));
|
||||
// A program that could not be started emits this and never exits, so without
|
||||
// it the launch waited out the handshake timeout and then reported the
|
||||
// kernel as silent — which sent the reader looking at the kernel.
|
||||
const onSpawnFailure = (err) => settle(reject, new HostStartError("spawn", `the kernel could not be started: ${err.message}`, { elapsedMs: elapsed() }));
|
||||
const onSpawnFailure = (err) => settle(reject, new Error(`the kernel could not be started: ${err.message}`));
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", onData);
|
||||
child.on("exit", onEarlyExit);
|
||||
@@ -167,12 +128,4 @@ function parse(line) {
|
||||
return { origin: url.origin, token: body.token };
|
||||
}
|
||||
|
||||
const RETRY_WITHIN_MS = 10000;
|
||||
|
||||
// shouldRetry allows one more attempt only for a kernel that stopped, or could
|
||||
// not be run, soon after it was started.
|
||||
function shouldRetry(err, attempt, elapsedMs) {
|
||||
return attempt < 2 && (err?.code === "exited" || err?.code === "spawn") && elapsedMs <= RETRY_WITHIN_MS;
|
||||
}
|
||||
|
||||
module.exports = { shouldRetry, start, parse, readActs, HostStartError, PROTOCOL_VERSION, HANDSHAKE_TIMEOUT_MS };
|
||||
module.exports = { start, parse, readActs, PROTOCOL_VERSION };
|
||||
@@ -26,10 +26,6 @@ class StudioHost {
|
||||
return this.json("GET", "/tray/state");
|
||||
}
|
||||
|
||||
trayRunning() {
|
||||
return this.json("GET", "/tray/running");
|
||||
}
|
||||
|
||||
// A refusal and an unreachable kernel both answer null here: every caller of
|
||||
// this is a surface that has to keep working when the kernel is going down.
|
||||
async json(method, path, body) {
|
||||
|
||||
Loaded 100 of 2018 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user