mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 17:10:43 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f73421aa65 | ||
|
|
1128e4dab9 | ||
|
|
44f87ce11e | ||
|
|
d44551ac21 | ||
|
|
680732c5ed | ||
|
|
2e534ca1aa | ||
|
|
e44afbc368 | ||
|
|
faf7999823 | ||
|
|
d44a65f3f1 | ||
|
|
5edd77861a | ||
|
|
7866a7b1d0 | ||
|
|
94b57ca1c0 | ||
|
|
9092e5ede6 | ||
|
|
d67faee3b5 | ||
|
|
6dcd0d3cc7 | ||
|
|
1da6e15962 | ||
|
|
5f07a893ab | ||
|
|
b9c95807f7 | ||
|
|
8de80b3fe7 | ||
|
|
c450fad954 | ||
|
|
c9a6aab619 | ||
|
|
97aab82b2d | ||
|
|
fa9d78ce14 | ||
|
|
a6374d9b9a | ||
|
|
e1d65e0377 | ||
|
|
51a94e6602 | ||
|
|
70a990428b | ||
|
|
226c567194 | ||
|
|
32a293b12a | ||
|
|
c6e3169e48 | ||
|
|
18e0cf8224 | ||
|
|
94d695393e | ||
|
|
7fab14208a | ||
|
|
b7ebb884b9 | ||
|
|
4b12befedf | ||
|
|
7ba9278f08 | ||
|
|
eaac0e8399 | ||
|
|
2872663c2d | ||
|
|
1d592d4e61 | ||
|
|
e5eb7b892b | ||
|
|
40a53ecd8d | ||
|
|
be9631d18a | ||
|
|
dcb3becaf9 | ||
|
|
78aeea4eac | ||
|
|
cc4f466a66 | ||
|
|
fe483f46d5 | ||
|
|
5b026929f4 | ||
|
|
c1f9dc9e1c | ||
|
|
e9f2b8a6ba | ||
|
|
4cc3f056eb | ||
|
|
f086775c3e | ||
|
|
8e5c0f3443 | ||
|
|
741b1da8cf | ||
|
|
63798bff1f | ||
|
|
6039ba386c | ||
|
|
d64c204309 | ||
|
|
9f2f03e703 | ||
|
|
6c193b2783 | ||
|
|
d383652b6f | ||
|
|
e01c8ed11a | ||
|
|
88514b8424 | ||
|
|
310b8f0702 | ||
|
|
e11b9b86f5 | ||
|
|
4f17c13990 | ||
|
|
49094ac587 | ||
|
|
de9ff2bed0 | ||
|
|
ceacf5ab25 | ||
|
|
3a3a720cda | ||
|
|
ad8cb13030 | ||
|
|
14004b18bb | ||
|
|
8a315bd011 | ||
|
|
4b3ad7c29e | ||
|
|
cb5c0e036c | ||
|
|
a591b7044e | ||
|
|
1092e8c8e2 | ||
|
|
041f40672d | ||
|
|
3bb435aa10 | ||
|
|
cb04e619db | ||
|
|
7245b82c6f | ||
|
|
44e9492be4 | ||
|
|
d8808e587b | ||
|
|
058e700b67 | ||
|
|
040f77bde0 | ||
|
|
f551c4bb60 | ||
|
|
389cc40beb | ||
|
|
26bf6547d9 | ||
|
|
9d04c2be14 | ||
|
|
5940eb155a | ||
|
|
e4aafd6d8e | ||
|
|
07472fb975 | ||
|
|
459deec872 | ||
|
|
d138102a09 | ||
|
|
68efeef577 | ||
|
|
6d0f9efcf6 | ||
|
|
43bd7accc2 | ||
|
|
0848ebfd50 | ||
|
|
7a4a690284 | ||
|
|
5a1720f6c9 | ||
|
|
f3dc18f9b1 | ||
|
|
693d0fb9b7 | ||
|
|
62d5c3378b | ||
|
|
1c35080ea9 | ||
|
|
1ecd331b15 | ||
|
|
90005c538f | ||
|
|
8c908a1fe8 | ||
|
|
34a8dea646 | ||
|
|
9a6c6828e6 | ||
|
|
4a563646c6 | ||
|
|
6ecdee4e7b | ||
|
|
28abc7995b | ||
|
|
924b1689d4 | ||
|
|
fda37354be | ||
|
|
9fed189018 | ||
|
|
b2e3576d20 | ||
|
|
9ab569c529 | ||
|
|
c94edbcba3 | ||
|
|
8f2b8d74cc | ||
|
|
c4e920fbf7 | ||
|
|
5f9080ee5e | ||
|
|
0ae7693526 | ||
|
|
bbdcef877e | ||
|
|
46bd67824b | ||
|
|
a8cf3c3f0d | ||
|
|
5f59e763a7 | ||
|
|
40a54854a3 | ||
|
|
ae57da39c1 | ||
|
|
e8d84adf69 | ||
|
|
d043eb6734 | ||
|
|
4f9072fd80 | ||
|
|
2cd9b0412d | ||
|
|
670cb0d20e | ||
|
|
8f2ec9c243 | ||
|
|
6e1956a9bb | ||
|
|
cdc88be082 | ||
|
|
f601e38b00 | ||
|
|
317a9acbe3 | ||
|
|
af36e46815 | ||
|
|
c6a8c45a5f | ||
|
|
304a1e1b03 | ||
|
|
2ab5f89f2c | ||
|
|
118caef283 | ||
|
|
6a10aebb55 | ||
|
|
f50b748304 | ||
|
|
dde8de4ded | ||
|
|
b76a0b6522 | ||
|
|
c379851293 | ||
|
|
55141b6447 | ||
|
|
5f018ae952 | ||
|
|
fa244e9bb8 | ||
|
|
09b0b4c1a4 | ||
|
|
011ab6d31d | ||
|
|
af6dfb36a2 | ||
|
|
420d408a65 | ||
|
|
d3c728e1bb | ||
|
|
31c29d9bf0 | ||
|
|
12c6f48b04 | ||
|
|
9c3ab6ffda | ||
|
|
28fdf58cbd | ||
|
|
5e58a9ff20 | ||
|
|
9d46b22774 | ||
|
|
f5b5553295 | ||
|
|
aa47d1afa4 | ||
|
|
47dec71c78 | ||
|
|
696b791be6 | ||
|
|
362c8381b5 | ||
|
|
e5c74a52c9 | ||
|
|
3b27b5c419 | ||
|
|
909ecd8544 | ||
|
|
d784c31d2b | ||
|
|
2ed678a37c | ||
|
|
0107a78275 | ||
|
|
cc0206dd25 | ||
|
|
47aff7d34f | ||
|
|
43a9069b46 | ||
|
|
72d8d622dd | ||
|
|
dd92486b5e | ||
|
|
431daf03c4 | ||
|
|
7b653ddd84 | ||
|
|
3f2a2760f4 | ||
|
|
42b1d2742a | ||
|
|
89a00b8310 | ||
|
|
7f30fcbdb3 | ||
|
|
7e6a90e954 | ||
|
|
95d0f747a8 | ||
|
|
39fbadf232 | ||
|
|
444a38cdd9 | ||
|
|
021685bc90 | ||
|
|
2e0354050c | ||
|
|
79cf8d34a1 | ||
|
|
e7d8dea94f | ||
|
|
cb934974a6 | ||
|
|
e41e54e173 | ||
|
|
e85f182fc8 | ||
|
|
fe6e76d41b | ||
|
|
4c2ac96604 | ||
|
|
8c48421711 | ||
|
|
04420cca07 | ||
|
|
de2b1d6240 | ||
|
|
d62e4bc71b | ||
|
|
b4564e5f8d | ||
|
|
aa0d80f798 | ||
|
|
89df335f78 | ||
|
|
da7ec275f6 | ||
|
|
98ba2b7e4e | ||
|
|
9d4a2bd6d3 | ||
|
|
a83f6c0229 | ||
|
|
8decb83466 | ||
|
|
8bb3f9d58c | ||
|
|
e13eb30dbc | ||
|
|
ae7fb0c1f5 | ||
|
|
7ac14f94af | ||
|
|
ed7dbd54e4 | ||
|
|
7f2365b4a0 | ||
|
|
7fa84ab3cc | ||
|
|
c434fc423f | ||
|
|
285b537f96 | ||
|
|
69cfaefdcc | ||
|
|
38c1519373 | ||
|
|
66723cc065 | ||
|
|
36e059b7f2 | ||
|
|
5d4f4f0259 | ||
|
|
34ea5a5d78 | ||
|
|
23eec9f92e | ||
|
|
2a412969b0 | ||
|
|
4669be38c6 | ||
|
|
9a6936ceb7 | ||
|
|
26e2eba5ea | ||
|
|
4024c63a43 | ||
|
|
722ceb4017 | ||
|
|
fa194e4f91 | ||
|
|
647d7670ba | ||
|
|
bd12c75dfa | ||
|
|
40e8ee8050 | ||
|
|
f80741bbd5 | ||
|
|
7f43425005 | ||
|
|
c050ffc1d4 | ||
|
|
5fcdd4f970 | ||
|
|
cc24f23cda | ||
|
|
97e6bb3dc1 | ||
|
|
c81f424100 | ||
|
|
9106bbe805 | ||
|
|
a9a726b921 | ||
|
|
15cefe039e | ||
|
|
a6eb4e1742 | ||
|
|
de66ad3240 | ||
|
|
ba338a2d6b | ||
|
|
bfcbf2a8ff | ||
|
|
80e8a1a82f | ||
|
|
0afafb7a46 | ||
|
|
e3e2ea39dd | ||
|
|
444ca4fa45 | ||
|
|
5fbb16f2d6 | ||
|
|
6ee31dedd0 | ||
|
|
defebb51b2 | ||
|
|
0427ff16f8 | ||
|
|
8f5701324e | ||
|
|
0333c008a9 | ||
|
|
f2631f18fc | ||
|
|
b924372004 | ||
|
|
e2b02ed555 | ||
|
|
65aef1e228 | ||
|
|
03ac4a5197 | ||
|
|
d4af1d6527 | ||
|
|
8156f643fc | ||
|
|
d194492f30 | ||
|
|
fd93411f81 | ||
|
|
49affe9889 | ||
|
|
9aa9756b46 | ||
|
|
e17c91e44f | ||
|
|
93f408bfd9 | ||
|
|
a08a5b60a7 | ||
|
|
099c1faa52 | ||
|
|
a9e7e22636 | ||
|
|
9ce1b8ba5e | ||
|
|
4c296c9320 | ||
|
|
a45ff5a771 | ||
|
|
74b5580b83 | ||
|
|
a4c97196ff | ||
|
|
0e026c95f0 | ||
|
|
1c85e79cc6 | ||
|
|
33a56d2f8a | ||
|
|
3a47847772 | ||
|
|
6b1e18db89 | ||
|
|
d2a7e55585 | ||
|
|
972f0212fa | ||
|
|
5561753a6d | ||
|
|
cf1a147bcb | ||
|
|
c4736c747c | ||
|
|
d1c534c8d6 | ||
|
|
10c6aade5f | ||
|
|
01edf16dbc | ||
|
|
ce66ba8742 | ||
|
|
731bc29ca5 | ||
|
|
90de42bf12 | ||
|
|
bc55e83c39 | ||
|
|
7ee3a554c4 | ||
|
|
215dedc5b4 | ||
|
|
20c883c724 | ||
|
|
f1bae78d39 | ||
|
|
f0e735084a | ||
|
|
e9108c3133 | ||
|
|
a91c2632d5 | ||
|
|
6afdcb47a0 | ||
|
|
fa978db05e | ||
|
|
57e6222d98 | ||
|
|
462a47f240 | ||
|
|
eca93427cc | ||
|
|
c7a6704558 | ||
|
|
7f3b72967d | ||
|
|
d5c963523d | ||
|
|
34c84f7a73 | ||
|
|
962771cbdb | ||
|
|
48365b2ecb | ||
|
|
198f60e6b4 | ||
|
|
4396615092 | ||
|
|
61c1e6cb68 | ||
|
|
8175a143f1 | ||
|
|
57ec3c2245 | ||
|
|
69d07fca45 | ||
|
|
cb6cd332d8 | ||
|
|
064405a2a7 | ||
|
|
aba9fe6134 | ||
|
|
ea7721b15f | ||
|
|
31725ae561 | ||
|
|
985853ac03 | ||
|
|
1e17d7f5c7 | ||
|
|
1a18ad9e0e | ||
|
|
3cb8c26bd7 | ||
|
|
b9bc20a6cb | ||
|
|
84688a91d8 | ||
|
|
8511f820e9 | ||
|
|
9a6df69e00 | ||
|
|
67f0b7b9f5 | ||
|
|
63df856224 | ||
|
|
f96b3677c2 | ||
|
|
f154bd95e5 | ||
|
|
5edd8ac898 | ||
|
|
ad5191854f | ||
|
|
04007d942f | ||
|
|
c701436015 | ||
|
|
0d69ac0f95 | ||
|
|
0397a3bed6 | ||
|
|
4c64d7cc61 | ||
|
|
312ba89ae6 | ||
|
|
a3d1142d7d | ||
|
|
5c3b9f0bed | ||
|
|
b7541b7c80 | ||
|
|
ac681f524d | ||
|
|
b977cbda91 | ||
|
|
e6655a6054 | ||
|
|
3e6f700acb | ||
|
|
770f24b278 | ||
|
|
9d96517857 | ||
|
|
4b4b324a81 | ||
|
|
f654aeabb6 |
No files matched your search
@@ -37,6 +37,7 @@
|
||||
/.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
|
||||
|
||||
@@ -42,3 +42,7 @@ 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)
|
||||
@@ -84,7 +84,7 @@ jobs:
|
||||
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/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
|
||||
- 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
|
||||
|
||||
# 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
|
||||
|
||||
@@ -5,10 +5,12 @@ 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 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.
|
||||
# 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.
|
||||
#
|
||||
# Windows artifacts are Authenticode-signed with a Certum SimplySign cloud
|
||||
# certificate in two stages: every PE file in the application tree, then the
|
||||
@@ -74,8 +76,7 @@ jobs:
|
||||
fi
|
||||
version="${tag#studio-}"
|
||||
# Semver's prerelease segment, group 4, read from the grammar rather
|
||||
# than the spelling. Every Studio release carries GitHub's prerelease
|
||||
# flag, so the tag-level distinction needs a name of its own.
|
||||
# than the spelling.
|
||||
candidate=false
|
||||
if [[ "${version#v}" =~ $semver ]] && [ -n "${BASH_REMATCH[4]}" ]; then
|
||||
candidate=true
|
||||
@@ -148,10 +149,21 @@ 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
|
||||
@@ -266,6 +278,7 @@ 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
|
||||
@@ -288,6 +301,14 @@ 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
|
||||
@@ -344,8 +365,8 @@ jobs:
|
||||
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
path: dist/win-unpacked
|
||||
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
|
||||
path: dist/${{ matrix.unpacked }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -356,17 +377,24 @@ 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/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip $arch \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis $arch \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
|
||||
@@ -382,22 +410,36 @@ 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)
|
||||
name: sign the Windows PE files (Certum, ${{ matrix.arch }})
|
||||
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: ${{ steps.verify.outputs.digest }}
|
||||
payload-digest-amd64: ${{ matrix.arch == 'amd64' && steps.verify.outputs.digest || '' }}
|
||||
payload-digest-arm64: ${{ matrix.arch == 'arm64' && 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.
|
||||
@@ -438,7 +480,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: unsigned-studio-windows-bundle
|
||||
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
|
||||
path: signing/app
|
||||
|
||||
# Stage one: every PE file in the application tree that Microsoft has
|
||||
@@ -485,6 +527,7 @@ 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
|
||||
@@ -492,7 +535,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: signing/app
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
@@ -500,10 +543,20 @@ 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
|
||||
name: package Windows from the signed tree (${{ matrix.arch }})
|
||||
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:
|
||||
@@ -536,8 +589,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
|
||||
path: dist/win-unpacked
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: dist/${{ matrix.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
|
||||
@@ -545,24 +598,27 @@ 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/win-unpacked ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip \
|
||||
cp -r ../../dist/${{ matrix.unpacked }} ../../zip-src
|
||||
pnpm exec electron-builder --prepackaged ../../zip-src --win zip --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}" -c.directories.output=../../dist-zip &
|
||||
zip_pid=$!
|
||||
pnpm exec electron-builder --prepackaged ../../dist/win-unpacked --win nsis \
|
||||
pnpm exec electron-builder --prepackaged ../../dist/${{ matrix.unpacked }} --win nsis --${{ matrix.ebarch }} \
|
||||
-c.extraMetadata.version="${version#v}"
|
||||
wait "$zip_pid"
|
||||
mv ../../dist-zip/ReasonixStudio-windows-amd64.zip ../../dist/
|
||||
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: |
|
||||
dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
dist/ReasonixStudio-windows-amd64.zip
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
dist/ReasonixStudio-windows-${{ matrix.arch }}.zip
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
@@ -571,15 +627,21 @@ 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
|
||||
name: check the Windows packages against the signed tree (${{ matrix.arch }})
|
||||
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: ${{ steps.check.outputs.installer-sha256 }}
|
||||
archive-sha256: ${{ steps.check.outputs.archive-sha256 }}
|
||||
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 || '' }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
@@ -588,12 +650,12 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: signed-studio-windows-payload
|
||||
name: signed-studio-windows-payload-${{ matrix.arch }}
|
||||
path: payload
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# 7-Zip is the runner image's, not the tag's.
|
||||
@@ -601,20 +663,26 @@ jobs:
|
||||
id: check
|
||||
shell: pwsh
|
||||
env:
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs.payload-digest }}
|
||||
PAYLOAD_DIGEST: ${{ needs.windows-sign-payload.outputs[format('payload-digest-{0}', matrix.arch)] }}
|
||||
run: |
|
||||
./scripts/verify-windows-package.ps1 `
|
||||
-PayloadDirectory payload `
|
||||
-ExpectedPayloadDigest $env:PAYLOAD_DIGEST `
|
||||
-InstallerPath "dist/ReasonixStudio-windows-amd64-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-amd64.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT
|
||||
-InstallerPath "dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe" `
|
||||
-PortableArchivePath "dist/ReasonixStudio-windows-${{ matrix.arch }}.zip" `
|
||||
-OutputPath $env:GITHUB_OUTPUT `
|
||||
-Architecture ${{ matrix.arch }}
|
||||
|
||||
windows-sign-installer:
|
||||
name: sign the Windows installer (Certum)
|
||||
name: sign the Windows installer (Certum, ${{ matrix.arch }})
|
||||
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
|
||||
@@ -623,7 +691,8 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
# The signed installer's hash, which publish requires of what it downloads.
|
||||
outputs:
|
||||
installer-sha256: ${{ steps.signed.outputs.installer-sha256 }}
|
||||
installer-sha256-amd64: ${{ matrix.arch == 'amd64' && steps.signed.outputs.installer-sha256 || '' }}
|
||||
installer-sha256-arm64: ${{ matrix.arch == 'arm64' && 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.
|
||||
@@ -664,7 +733,7 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: packaged-studio-windows
|
||||
name: packaged-studio-windows-${{ matrix.arch }}
|
||||
path: dist
|
||||
|
||||
# Only the two files windows-verify-package checked, byte for byte. This
|
||||
@@ -673,13 +742,13 @@ jobs:
|
||||
- name: Require the checked packages
|
||||
shell: pwsh
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-verify-package.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
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)] }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$expected = [ordered]@{
|
||||
'ReasonixStudio-windows-amd64-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-amd64.zip' = $env:ARCHIVE_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}-installer.exe' = $env:INSTALLER_SHA256
|
||||
'ReasonixStudio-windows-${{ matrix.arch }}.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 ', ')" }
|
||||
@@ -701,7 +770,7 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
CERTUM_KEY_ID: ${{ secrets.CERTUM_KEY_ID }}
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-amd64-installer.exe
|
||||
run: ./scripts/sign-certum.ps1 -FilePath dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe
|
||||
|
||||
# The session signs for any process on the runner while it is up.
|
||||
- name: Disconnect from Certum
|
||||
@@ -717,7 +786,7 @@ jobs:
|
||||
EXPECTED_SUBJECT: ${{ vars.STUDIO_SIGNING_SUBJECT }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$installer = 'dist/ReasonixStudio-windows-amd64-installer.exe'
|
||||
$installer = 'dist/ReasonixStudio-windows-${{ matrix.arch }}-installer.exe'
|
||||
./scripts/verify-windows-authenticode.ps1 `
|
||||
-FilePath $installer `
|
||||
-ExpectedThumbprint $env:CERTUM_KEY_ID `
|
||||
@@ -727,11 +796,60 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
name: studio-windows-signed-${{ matrix.arch }}
|
||||
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
|
||||
@@ -802,12 +920,13 @@ jobs:
|
||||
|
||||
publish:
|
||||
name: sign, publish and mirror
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer]
|
||||
needs: [resolve, build, cli, windows-sign-payload, windows-package, windows-verify-package, windows-sign-installer, windows-install-smoke]
|
||||
# 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-sign-payload.result == 'success' &&
|
||||
needs.cli.result == 'success' && needs.windows-install-smoke.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' &&
|
||||
@@ -869,43 +988,63 @@ jobs:
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
with:
|
||||
name: studio-windows-signed
|
||||
path: staging/studio-windows
|
||||
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
|
||||
|
||||
# Any job can upload an artifact, so the signed Windows packages are
|
||||
# taken only if they are the bytes the signing jobs recorded: the
|
||||
# installer as signed, the zip as windows-verify-package checked it.
|
||||
# taken only if they are the bytes the signing jobs recorded: each
|
||||
# installer as signed, each zip as windows-verify-package checked it.
|
||||
- name: Require the recorded Windows packages
|
||||
if: needs.windows-sign-installer.result == 'success'
|
||||
env:
|
||||
INSTALLER_SHA256: ${{ needs.windows-sign-installer.outputs.installer-sha256 }}
|
||||
ARCHIVE_SHA256: ${{ needs.windows-verify-package.outputs.archive-sha256 }}
|
||||
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')] }}
|
||||
run: |
|
||||
check() {
|
||||
local file="staging/studio-windows/$1" want="$2" got
|
||||
local file="$1/$2" want="$3" got
|
||||
if [[ ! "$want" =~ ^[0-9a-f]{64}$ ]]; then
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $1"
|
||||
echo "::error title=studio-signing.package-hash-missing::no recorded SHA-256 for $2"
|
||||
return 1
|
||||
fi
|
||||
got="$(sha256sum "$file" | awk '{print $1}')" || return 1
|
||||
if [ "$got" != "$want" ]; then
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$1 is $got, the signing jobs recorded $want"
|
||||
echo "::error title=studio-signing.package-hash-mismatch::$2 is $got, the signing jobs recorded $want"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
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"
|
||||
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
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -928,13 +1067,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), 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) 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.
|
||||
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
|
||||
if [ "$count" -lt 7 ]; then
|
||||
echo "::error::expected 7 artifacts (Windows installer + zip, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
if [ "$count" -lt 9 ]; then
|
||||
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
|
||||
exit 1
|
||||
fi
|
||||
# Each platform's update channel, and there is exactly one per
|
||||
@@ -945,7 +1084,11 @@ 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.
|
||||
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-windows-*-installer.exe' \
|
||||
# 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' \
|
||||
'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"
|
||||
@@ -994,12 +1137,14 @@ 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
|
||||
|
||||
# Always a prerelease: this line is not the public product line, and
|
||||
# GitHub never promotes a prerelease to the repository-wide "latest".
|
||||
# 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.
|
||||
- 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: |
|
||||
@@ -1040,15 +1185,20 @@ 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" dist/* --clobber
|
||||
gh release upload "$TAG" "${assets[@]}" --clobber
|
||||
else
|
||||
gh release create "$TAG" dist/* \
|
||||
decided="$(git tag -l 'studio-v*' | bash scripts/studio-release-flags.sh "$TAG" "$CANDIDATE")"
|
||||
mapfile -t flags <<<"$decided"
|
||||
gh release create "$TAG" "${assets[@]}" \
|
||||
--title "Reasonix Studio $VERSION" \
|
||||
--notes-file "$notes" \
|
||||
--prerelease \
|
||||
--latest=false
|
||||
"${flags[@]}"
|
||||
fi
|
||||
|
||||
- name: Mirror to R2 and update the Studio catalog
|
||||
@@ -1092,6 +1242,22 @@ 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
|
||||
@@ -1103,7 +1269,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)" > "$next"
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${notes_arg[@]}" > "$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" \
|
||||
|
||||
+203
-12
@@ -81,6 +81,64 @@ 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 }}
|
||||
@@ -121,6 +179,45 @@ 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
|
||||
@@ -128,20 +225,12 @@ 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:
|
||||
include:
|
||||
- os: macos-latest
|
||||
arch: arm64
|
||||
platform: darwin
|
||||
- os: ubuntu-22.04
|
||||
arch: amd64
|
||||
platform: linux
|
||||
- os: windows-latest
|
||||
arch: amd64
|
||||
platform: windows
|
||||
matrix: ${{ fromJSON(needs.scope.outputs.matrix) }}
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -174,8 +263,13 @@ 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
|
||||
@@ -191,7 +285,8 @@ jobs:
|
||||
working-directory: desktop/electron
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
run: pnpm run build:dist
|
||||
ELECTRON_BUILDER_7Z_FILTER: ${{ matrix.platform == 'windows' && matrix.arch == 'arm64' && 'BCJ' || '' }}
|
||||
run: pnpm run build:dist ${{ matrix.ebarch && format('--{0}', matrix.ebarch) || '' }}
|
||||
|
||||
# The release vocabulary is GOARCH. electron-builder's own is not, and the
|
||||
# rename that reconciles them runs here rather than in the manifest.
|
||||
@@ -207,6 +302,78 @@ 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
|
||||
@@ -301,3 +468,27 @@ 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' }
|
||||
@@ -11,6 +11,7 @@ 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,6 +181,28 @@ 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:
|
||||
|
||||
@@ -63,7 +63,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, pre-release | [Studio releases](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true) |
|
||||
| **Reasonix 2.x** | `studio` (this branch) | Active development | [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 +106,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.*`, published as pre-releases while 2.x is in active
|
||||
development):
|
||||
(tagged `studio-v2.*`; candidates such as `-rc.N` are marked as
|
||||
pre-releases):
|
||||
|
||||
| Platform | Package | Architecture |
|
||||
| --- | --- | --- |
|
||||
|
||||
+2
-2
@@ -61,7 +61,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 +95,7 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
|
||||
### Reasonix Studio(2.x)
|
||||
|
||||
从最新的 [Studio 发布](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`,2.x 活跃开发期间以预发布形式发布):
|
||||
下载对应平台的安装包(标签为 `studio-v2.*`;`-rc.N` 等候选版本标为预发布):
|
||||
|
||||
| 平台 | 安装包 | 架构 |
|
||||
| --- | --- | --- |
|
||||
|
||||
+5
-3
@@ -219,9 +219,11 @@ 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 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.
|
||||
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.
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -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 continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "## Earlier work\nEarlier work continued across several files; nothing outstanding was recorded."}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
alpha=QORVEX-7741
|
||||
beta=MIRALD-3208
|
||||
gamma=TESSIK-9155
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "为本项目新建一个数据库迁移脚本文件(空文件即可),文件名必须符合项目的迁移命名规范。"
|
||||
class = "cjk"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-CJK5T"]
|
||||
@@ -1,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,4 +1,3 @@
|
||||
prompt = "Add a function add(a, b) returning a+b to util.py."
|
||||
class = "generic"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
@@ -1,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,6 +1,5 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
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,5 +1,4 @@
|
||||
prompt = "查一下灰度的放量比例,写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1GR"]
|
||||
@@ -1,5 +1,4 @@
|
||||
prompt = "这个项目的部署审批人是谁?把审批人的用户名写进 answer.txt。"
|
||||
class = "v1miss"
|
||||
max_steps = 8
|
||||
timeout_sec = 180
|
||||
memory_markers = ["MEMKEY-VM1XL"]
|
||||
@@ -1,5 +1,4 @@
|
||||
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"]
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,3 @@
|
||||
def total(items):
|
||||
"""Sum the items."""
|
||||
return sum(x for x in items if x > 0)
|
||||
@@ -0,0 +1,2 @@
|
||||
value=7
|
||||
file=limits/budget.py
|
||||
@@ -0,0 +1,2 @@
|
||||
handler=h07.py
|
||||
missing=validate_payload
|
||||
@@ -28,6 +28,15 @@ 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.
|
||||
@@ -180,7 +189,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} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -233,7 +242,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} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
|
||||
if !dirExists(dir) {
|
||||
continue
|
||||
}
|
||||
@@ -250,7 +259,7 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
|
||||
t.Parallel()
|
||||
work := stageSeed(t, task.dir)
|
||||
if !stageSolved(t, task.dir, work) {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir {
|
||||
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
|
||||
t.Fatal("no solution/: a corpus task must prove its grader can pass")
|
||||
}
|
||||
t.Skip("no solution/: the e2e suite does not commit reference solutions")
|
||||
@@ -336,7 +345,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} {
|
||||
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
|
||||
tasks, err := loadTasks(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load %s: %v", dir, err)
|
||||
|
||||
@@ -316,7 +316,7 @@ 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(cfg, shell.version))
|
||||
reporter := telemetry.Start(studioTelemetryOptions(ctx, cfg, shell.version))
|
||||
if cfg.DesktopMetrics() {
|
||||
paneSink = reporter.Wrap(paneSink)
|
||||
}
|
||||
@@ -423,12 +423,22 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
|
||||
go host.Run(ctx)
|
||||
}
|
||||
|
||||
func studioTelemetryOptions(cfg *config.Config, studioVersion string) telemetry.Options {
|
||||
// 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 {
|
||||
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,12 +26,67 @@ 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(cfg, "2.20.2")
|
||||
opts := studioTelemetryOptions(context.Background(), cfg, "2.20.2")
|
||||
if opts.Surface != surface.Studio {
|
||||
t.Fatalf("surface = %q, want studio", opts.Surface)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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,3 +189,46 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,17 @@
|
||||
# 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
|
||||
|
||||
@@ -45,22 +45,35 @@ 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");
|
||||
|
||||
function msvcVars() {
|
||||
// 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) {
|
||||
const vswhere = path.join(VS_INSTALLER, "vswhere.exe");
|
||||
if (!fs.existsSync(vswhere)) return "";
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", "-property", "installationPath"], { encoding: "utf8" });
|
||||
const found = spawnSync(vswhere, ["-latest", "-products", "*", "-requires", toolchain.component, "-property", "installationPath"], { encoding: "utf8" });
|
||||
const root = (found.stdout || "").trim().split(/\r?\n/)[0];
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", "vcvars64.bat") : "";
|
||||
const vars = root ? path.join(root, "VC", "Auxiliary", "Build", toolchain.vars) : "";
|
||||
return vars && fs.existsSync(vars) ? vars : "";
|
||||
}
|
||||
|
||||
function buildWindowsHelper() {
|
||||
const vars = msvcVars();
|
||||
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);
|
||||
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 x64 C++ tools is required to build reasonix-computer-helper.exe");
|
||||
console.error(`MSVC with the ${toolchain.label} 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");
|
||||
@@ -91,9 +104,12 @@ function buildWindowsHelper() {
|
||||
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.
|
||||
go(["build", "-ldflags", LDFLAGS, "-o", OUT + path.sep, PKG], { CGO_ENABLED: "0" });
|
||||
// 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" });
|
||||
console.log("built reasonix-studio-host");
|
||||
if (process.platform === "win32") buildWindowsHelper();
|
||||
if (process.platform === "win32") buildWindowsHelper(goarch || (process.arch === "arm64" ? "arm64" : "amd64"));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"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);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
"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
|
||||
@@ -16,13 +17,16 @@ function guestNavigationAllowed(raw, kernelOrigin) {
|
||||
}
|
||||
|
||||
// What the person typed, as the address to load and the one to fall back to.
|
||||
// Only "scheme://" or about: names a scheme: "intranet:8080" is a host and a
|
||||
// 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
|
||||
// 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 = String(raw || "").trim();
|
||||
const text = trimTyped(raw);
|
||||
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 {
|
||||
@@ -34,6 +38,24 @@ 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.
|
||||
@@ -58,4 +80,4 @@ function privateHost(host) {
|
||||
return h === "localhost" || h.endsWith(".localhost") || !h.includes(".");
|
||||
}
|
||||
|
||||
module.exports = { guestNavigationAllowed, typedAddress, privateHost };
|
||||
module.exports = { guestNavigationAllowed, typedAddress, typed, privateHost };
|
||||
@@ -2,7 +2,7 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { WebContentsView, session } = require("electron");
|
||||
const { installPaneReload } = require("./reload");
|
||||
const { guestNavigationAllowed, typedAddress } = require("./browserguard");
|
||||
const { guestNavigationAllowed, typed } = 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
|
||||
@@ -221,12 +221,15 @@ 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 } = typedAddress(address);
|
||||
if (!entry || !guestNavigationAllowed(url, this.kernelOrigin)) return false;
|
||||
const { url, fallback, refusal } = typed(address, this.kernelOrigin);
|
||||
if (!entry) return "scheme";
|
||||
if (refusal) return refusal;
|
||||
void this.loadTyped(targetId, entry, url, fallback);
|
||||
return true;
|
||||
return "";
|
||||
}
|
||||
|
||||
// loadTyped owns the outcome of an address the person typed. loadURL's own
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"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 };
|
||||
@@ -26,6 +26,10 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
"use strict";
|
||||
|
||||
// What a typed address is when it names a place on disk. Only a form that cannot
|
||||
// be a host claims it: a drive letter and a separator, a leading "\\" (also
|
||||
// "\\?\" and "\\.\"), one leading slash, or a file: URL. "D:foo", "c:",
|
||||
// "host:8080" and "//host/x" are not paths. A file: URL for another host, or one
|
||||
// whose path opens with a second slash once escapes and dot segments resolve,
|
||||
// is a share: Windows reads //host/... as one. internal/platform/browser/
|
||||
// localpath.go reads the same table (testdata/local_paths.json).
|
||||
const SPACE = "\\t-\\r \\u0085\\u00a0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000\\ufeff";
|
||||
const TRIM = new RegExp(`^[${SPACE}]+|[${SPACE}]+$`, "g");
|
||||
|
||||
function trimTyped(raw) {
|
||||
return String(raw ?? "").replace(TRIM, "");
|
||||
}
|
||||
|
||||
function localPath(raw) {
|
||||
const text = trimTyped(raw).toWellFormed();
|
||||
const flat = text.replace(/[\t\n\r]/g, "");
|
||||
if (/^file:\//i.test(flat)) return fileForm(flat.slice(5));
|
||||
if (text.startsWith("\\\\?\\") || text.startsWith("\\\\.\\")) return deviceForm(text.slice(4));
|
||||
const drive = driveSplit(text);
|
||||
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
|
||||
if (text.startsWith("\\\\")) return uncForm(text.slice(2));
|
||||
if (text.startsWith("/") && !text.startsWith("//")) {
|
||||
return { url: `file:///${segments(text.slice(1), /\//)}`, kind: "local" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function driveSplit(text) {
|
||||
const m = /^\/?([A-Za-z]):[\\/](.*)$/s.exec(text);
|
||||
return m ? [m[1], m[2]] : null;
|
||||
}
|
||||
|
||||
function uncForm(rest) {
|
||||
const cut = rest.search(/[\\/]/);
|
||||
const host = cut < 0 ? rest : rest.slice(0, cut);
|
||||
if (!host) return null;
|
||||
const share = cut < 0 ? "" : rest.slice(cut + 1);
|
||||
return { url: `file://${encodeURIComponent(host)}/${segments(share, /[\\/]/)}`, kind: "network" };
|
||||
}
|
||||
|
||||
function deviceForm(rest) {
|
||||
if (/^unc[\\/]/i.test(rest)) return uncForm(rest.slice(4));
|
||||
const drive = driveSplit(rest);
|
||||
if (drive) return { url: `file:///${drive[0]}:/${segments(drive[1], /[\\/]/)}`, kind: "local" };
|
||||
return { url: "", kind: "network" };
|
||||
}
|
||||
|
||||
function fileForm(afterScheme) {
|
||||
if (/[\u0000-\u001f\u007f]/.test(afterScheme)) return { url: "", kind: "invalid" };
|
||||
let rest = afterScheme.replaceAll("\\", "/");
|
||||
let host = "";
|
||||
if (rest.startsWith("//")) {
|
||||
const cut = rest.indexOf("/", 2);
|
||||
host = cut < 0 ? rest.slice(2) : rest.slice(2, cut);
|
||||
rest = cut < 0 ? "/" : rest.slice(cut);
|
||||
}
|
||||
const tailAt = rest.search(/[?#]/);
|
||||
let path = tailAt < 0 ? rest : rest.slice(0, tailAt);
|
||||
const tail = tailAt < 0 ? "" : rest.slice(tailAt);
|
||||
if (/^[A-Za-z]:$/.test(host)) path = "/" + host + path;
|
||||
else if (host !== "" && host.toLowerCase() !== "localhost") return { url: "", kind: "network" };
|
||||
if (path === "") path = "/";
|
||||
if (opensShare(path)) return { url: "", kind: "network" };
|
||||
return { url: "file://" + path + tail, kind: "local" };
|
||||
}
|
||||
|
||||
function opensShare(path) {
|
||||
const out = [];
|
||||
for (const seg of path.replace(/%5c|%2f/gi, "/").replace(/%2e/gi, ".").split("/").slice(1)) {
|
||||
if (seg === ".") continue;
|
||||
if (seg === "..") out.pop();
|
||||
else out.push(seg);
|
||||
}
|
||||
return out.length > 1 && out[0] === "";
|
||||
}
|
||||
|
||||
function segments(rest, sep) {
|
||||
const parts = rest.split(sep).filter(Boolean).map(encodeURIComponent);
|
||||
const joined = parts.join("/");
|
||||
return parts.length && sep.test(rest.slice(-1)) ? joined + "/" : joined;
|
||||
}
|
||||
|
||||
module.exports = { localPath, trimTyped };
|
||||
@@ -1,5 +1,5 @@
|
||||
"use strict";
|
||||
const { app, BrowserWindow, dialog, ipcMain, screen, session, shell } = require("electron");
|
||||
const { app, BrowserWindow, dialog, ipcMain, nativeTheme, powerSaveBlocker, screen, session, shell } = require("electron");
|
||||
const { relaunchForOzonePlatform } = require("./ozone");
|
||||
|
||||
// Before the instance lock: this process must hold nothing its relaunch needs.
|
||||
@@ -25,7 +25,9 @@ const { stripPackageGrants, unpaintedWindowCause } = require("./packagegrants");
|
||||
const { BrowserProtocol } = require("./browserprotocol");
|
||||
const { BrowserViews } = require("./browserviews");
|
||||
const { startBrowserRelay } = require("./browserrelay");
|
||||
const { groundFor } = require("./ground");
|
||||
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
|
||||
const { createPowerGuard, keepsAwake } = require("./powerguard");
|
||||
const { openLogs, redactArgv, failStartup } = require("./shelllog");
|
||||
|
||||
// A page in a minimized or fully covered window counts as hidden, and a hidden
|
||||
@@ -70,6 +72,7 @@ let grants = null;
|
||||
let browserViews = null;
|
||||
let browserRelay = null;
|
||||
let reload = null;
|
||||
let powerGuard = null;
|
||||
let logs = null;
|
||||
let handshaken = false;
|
||||
|
||||
@@ -116,6 +119,7 @@ async function launchKernel(args) {
|
||||
logs.shell.line(`host: exited code=${code} signal=${signal}${handshaken ? "" : " before its handshake"}`);
|
||||
// Before the handshake the launch itself fails, and boot's catch owns
|
||||
// telling the person why; quitting here would race that dialog.
|
||||
powerGuard?.close();
|
||||
if (handshaken && code !== 0 && !quitting) app.quit();
|
||||
},
|
||||
onAct: handOver,
|
||||
@@ -160,6 +164,17 @@ async function boot() {
|
||||
origin = ready.origin;
|
||||
client = new StudioHost(ready.origin, ready.token);
|
||||
await armCredential(ready);
|
||||
powerGuard = createPowerGuard({
|
||||
blocker: powerSaveBlocker,
|
||||
running: () => client.trayRunning(),
|
||||
enabled: () => keepsAwake(loadPrefs(prefsFile(app.getPath("userData")))),
|
||||
});
|
||||
powerGuard.begin();
|
||||
// The poll alone leaves a turn unprotected for up to its period; the page's
|
||||
// own submit is the moment one starts, and this process sees it complete.
|
||||
session.defaultSession.webRequest.onCompleted({ urls: [`${ready.origin}/*submit`] }, (details) => {
|
||||
if (details.method === "POST" && details.statusCode < 300) void powerGuard?.refresh();
|
||||
});
|
||||
win = createWindow();
|
||||
closeStarting();
|
||||
guard(win.webContents);
|
||||
@@ -271,6 +286,8 @@ function fitted() {
|
||||
};
|
||||
}
|
||||
|
||||
const ground = () => groundFor(loadPrefs(prefsFile(app.getPath("userData"))), nativeTheme.shouldUseDarkColors);
|
||||
|
||||
function createWindow() {
|
||||
const mac = process.platform === "darwin";
|
||||
const windows = process.platform === "win32";
|
||||
@@ -283,6 +300,7 @@ function createWindow() {
|
||||
// Shown once it has been measured against the screen it landed on; sizing a
|
||||
// visible window makes the correction a flicker.
|
||||
show: false,
|
||||
backgroundColor: ground(),
|
||||
frame: !windows,
|
||||
titleBarStyle: mac ? "hiddenInset" : "default",
|
||||
...(mac ? { trafficLightPosition: LIGHTS } : {}),
|
||||
@@ -319,7 +337,14 @@ function fromWindow(event) {
|
||||
// The page reads navigator.languages[0], which is the first preferred system
|
||||
// language; app.getLocale() is the locale Chromium resolved, which can differ.
|
||||
const uiLang = () => uiLanguage(loadPrefs(prefsFile(app.getPath("userData"))), app.getPreferredSystemLanguages()[0] ?? app.getLocale());
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => installApplicationMenu(uiLang));
|
||||
registerPrefs(ipcMain, () => prefsFile(app.getPath("userData")), fromWindow, () => {
|
||||
installApplicationMenu(uiLang);
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
void powerGuard?.refresh();
|
||||
});
|
||||
nativeTheme.on("updated", () => {
|
||||
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
|
||||
});
|
||||
|
||||
ipcMain.handle("window:minimise", (event) => {
|
||||
fromWindow(event)?.minimize();
|
||||
@@ -345,7 +370,7 @@ ipcMain.handle("browser:control", (event, targetId, action) => {
|
||||
if (fromWindow(event)) browserViews?.control(String(targetId), String(action));
|
||||
});
|
||||
ipcMain.handle("browser:navigate", (event, targetId, address) =>
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? false) : false,
|
||||
fromWindow(event) ? (browserViews?.navigate(String(targetId), String(address)) ?? "scheme") : "scheme",
|
||||
);
|
||||
ipcMain.handle("browser:trust-certificate", (event, targetId) =>
|
||||
fromWindow(event) ? (browserViews?.trustCertificate(String(targetId)) ?? false) : false,
|
||||
@@ -498,6 +523,7 @@ app.on("before-quit", () => {
|
||||
quitting = true;
|
||||
logs.shell.line("shell: quitting");
|
||||
browserRelay?.stop();
|
||||
powerGuard?.close();
|
||||
tray?.close();
|
||||
kernel?.child.stdin.end();
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const KIND = "prevent-app-suspension";
|
||||
const PREF_KEY = "rx-keep-awake";
|
||||
// Consecutive idle answers before the block is let go: about fifteen seconds,
|
||||
// so a turn that ends and another that starts do not flap it.
|
||||
const IDLE_PASSES = 3;
|
||||
// Consecutive passes with no usable answer before the block is let go anyway:
|
||||
// about a minute, so a kernel that never answers again cannot hold it forever.
|
||||
const SILENT_PASSES = 12;
|
||||
|
||||
// The page stores the preference as "off" or "on"; anything else is the default.
|
||||
function keepsAwake(prefs) {
|
||||
return prefs?.[PREF_KEY] !== "off";
|
||||
}
|
||||
|
||||
// createPowerGuard holds one system-suspension block while the kernel reports a
|
||||
// turn in flight. running resolves to { panes, unknown } or rejects/returns
|
||||
// null when the kernel itself cannot answer. A pane count above zero takes the
|
||||
// block at once; letting go needs IDLE_PASSES clean zeros in a row, and a pass
|
||||
// that could not be answered neither takes it nor counts toward letting go.
|
||||
function createPowerGuard({ blocker, running, enabled, setInterval: every = setInterval, clearInterval: stop = clearInterval }) {
|
||||
let held = null;
|
||||
let pass = 0;
|
||||
let timer = null;
|
||||
let closed = false;
|
||||
let idle = 0;
|
||||
let silent = 0;
|
||||
|
||||
const hold = () => {
|
||||
if (held !== null && !blocker.isStarted(held)) held = null;
|
||||
if (held === null) held = blocker.start(KIND);
|
||||
};
|
||||
const release = () => {
|
||||
idle = 0;
|
||||
silent = 0;
|
||||
if (held === null) return;
|
||||
if (blocker.isStarted(held)) blocker.stop(held);
|
||||
held = null;
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
if (closed) return;
|
||||
const mine = ++pass;
|
||||
if (!enabled()) {
|
||||
release();
|
||||
return;
|
||||
}
|
||||
let answer = null;
|
||||
try {
|
||||
answer = await running();
|
||||
} catch {
|
||||
answer = null;
|
||||
}
|
||||
if (mine !== pass || closed) return;
|
||||
const panes = typeof answer?.panes === "number" ? answer.panes : null;
|
||||
const unknown = Number(answer?.unknown) > 0;
|
||||
if (panes !== null && panes > 0) {
|
||||
idle = 0;
|
||||
silent = 0;
|
||||
hold();
|
||||
} else if (panes === 0 && !unknown) {
|
||||
silent = 0;
|
||||
if (held === null || ++idle >= IDLE_PASSES) release();
|
||||
} else if (held !== null && ++silent >= SILENT_PASSES) {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
held: () => held !== null,
|
||||
begin() {
|
||||
if (timer) return;
|
||||
timer = every(() => void refresh(), POLL_MS);
|
||||
timer.unref?.();
|
||||
void refresh();
|
||||
},
|
||||
close() {
|
||||
closed = true;
|
||||
pass++;
|
||||
if (timer) stop(timer);
|
||||
timer = null;
|
||||
release();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createPowerGuard, keepsAwake, PREF_KEY, POLL_MS, KIND, IDLE_PASSES, SILENT_PASSES };
|
||||
@@ -12,6 +12,7 @@ const { uiLanguage } = require("../src/uilang.js");
|
||||
const { externalTarget } = require("../src/links.js");
|
||||
const { offerCleanup, ownBundle } = require("../src/legacy.js");
|
||||
const { stripPackageGrants, readReport, unpaintedWindowCause } = require("../src/packagegrants.js");
|
||||
const { groundFor, GROUND } = require("../src/ground.js");
|
||||
const { pick, loadPrefs, savePrefs, registerPrefs } = require("../src/prefs.js");
|
||||
|
||||
const TOKEN = "a".repeat(64);
|
||||
@@ -682,7 +683,8 @@ test("an unpainted window is attributed only to grants the kernel could not remo
|
||||
});
|
||||
|
||||
const { BrowserProtocol, PAGE_SESSION } = require("../src/browserprotocol.js");
|
||||
const { guestNavigationAllowed, typedAddress } = require("../src/browserguard.js");
|
||||
const { guestNavigationAllowed, typedAddress, typed } = require("../src/browserguard.js");
|
||||
const { localPath } = require("../src/localpath.js");
|
||||
const { sseData } = require("../src/browserrelay.js");
|
||||
|
||||
function fakeBrowser() {
|
||||
@@ -805,6 +807,65 @@ test("an address that names its scheme is loaded as written", () => {
|
||||
assert.equal(guestNavigationAllowed(typedAddress("file:///C:/x").url, "http://127.0.0.1:1"), false);
|
||||
});
|
||||
|
||||
test("a typed path on disk is the file it names, read by the table the kernel is held to", () => {
|
||||
const table = JSON.parse(fs.readFileSync(new URL("../../../internal/platform/browser/testdata/local_paths.json", import.meta.url), "utf8"));
|
||||
for (const { in: raw, out, kind } of table) {
|
||||
const got = localPath(raw);
|
||||
assert.deepEqual(got ? { url: got.url, kind: got.kind } : { url: "", kind: "" }, { url: out, kind }, JSON.stringify(raw));
|
||||
}
|
||||
});
|
||||
|
||||
test("a seeded corpus of hostile addresses reaches the same digest as the kernel's parser, and never accepts a share", () => {
|
||||
const tokens = [
|
||||
"file:", "file:/", "/", "//", "\\", ":", "%", "5C", "2F", "09", "0A", ".", "..", "#", "?",
|
||||
"D", "c", "$", " ", "h", "x", "\t", "\n", "\r", "", "\u0085", "\u0001", "\u007f",
|
||||
"localhost", "C:", "UNC", "%5c", "%2e", "?\\", " ",
|
||||
];
|
||||
let a = 12321;
|
||||
const next = () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0;
|
||||
let t = a;
|
||||
t = Math.imul(t ^ (t >>> 15), t | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return (t ^ (t >>> 14)) >>> 0;
|
||||
};
|
||||
let h = 0x811c9dc5;
|
||||
let accepted = 0;
|
||||
for (let i = 0; i < 30000; i++) {
|
||||
let input = next() % 2 === 0 ? "file:" : "";
|
||||
for (let n = 1 + (next() % 12); n > 0; n--) input += tokens[next() % tokens.length];
|
||||
const got = localPath(input);
|
||||
const out = got ? got.url : "";
|
||||
const kind = got ? got.kind : "";
|
||||
for (const byte of Buffer.from(`${input}\0${out}\0${kind}\n`, "utf8")) h = Math.imul(h ^ byte, 0x01000193) >>> 0;
|
||||
if (kind !== "local") continue;
|
||||
accepted++;
|
||||
const u = new URL(out);
|
||||
assert.ok(u.protocol === "file:" && u.hostname === "" && !u.pathname.startsWith("//"), `${JSON.stringify(input)} accepted as ${out}`);
|
||||
assert.ok(!/[\u0000-\u001f\u007f]/.test(out), `${JSON.stringify(input)} kept a control character`);
|
||||
}
|
||||
assert.ok(accepted > 1000, `only ${accepted} accepted`);
|
||||
assert.equal(h.toString(16).padStart(8, "0"), "67b363c1");
|
||||
});
|
||||
|
||||
test("a path typed in the address bar loads as a file, not as a host named by its drive letter", () => {
|
||||
const kernel = "http://127.0.0.1:4455";
|
||||
const refusal = (raw) => typed(raw, kernel).refusal;
|
||||
const reporter = typed("D:/DevCode/MyProjects/aglo/aglo.html", kernel);
|
||||
assert.deepEqual(reporter, { url: "file:///D:/DevCode/MyProjects/aglo/aglo.html", fallback: "", refusal: "" });
|
||||
for (const ok of ["/Users/me/页面.html", "file:///D:/x.html", "file:/tmp/x.html", "file://localhost/tmp/x.html", "localhost:3000/x"]) {
|
||||
assert.equal(refusal(ok), "", ok);
|
||||
}
|
||||
for (const share of ["\\\\nas\\share\\x.html", "file://nas/share/x.html", "file:////host/x", "file://localhost//host/x", "file://///host/share", "file:///\\\\host\\x", "file:///%5C%5Chost/x", "file:///%2F%2Fhost/x", "\\\\?\\UNC\\host\\x"]) {
|
||||
assert.deepEqual(typed(share, kernel), { url: "", fallback: "", refusal: "network_file" }, share);
|
||||
}
|
||||
assert.equal(refusal("javascript://x%0Aalert(1)"), "scheme");
|
||||
assert.equal(refusal("http://127.0.0.1:4455/_studio/"), "scheme");
|
||||
assert.equal(refusal(""), "scheme");
|
||||
assert.deepEqual(typedAddress("localhost:3000/x"), { url: "http://localhost:3000/x", fallback: "" });
|
||||
assert.deepEqual(typedAddress("a.b/c"), { url: "https://a.b/c", fallback: "http://a.b/c" });
|
||||
});
|
||||
|
||||
test("the relay reads whole SSE data frames and keeps what is unfinished", () => {
|
||||
const first = sseData(': connected\n\ndata: {"conn":"1"}\n\ndata: {"co');
|
||||
assert.deepEqual(first.data, ['{"conn":"1"}']);
|
||||
@@ -1396,3 +1457,267 @@ test("the tray has a file for every Windows scale, at exactly 16 * scale pixels"
|
||||
assert.equal(trayAsset(5).pixels, 48);
|
||||
assert.equal(trayAsset(NaN).pixels, 16);
|
||||
});
|
||||
|
||||
function pe(machine) {
|
||||
const b = Buffer.alloc(0x100);
|
||||
b.writeUInt16LE(0x5a4d, 0);
|
||||
b.writeUInt32LE(0x80, 0x3c);
|
||||
b.writeUInt32LE(0x00004550, 0x80);
|
||||
b.writeUInt16LE(machine, 0x84);
|
||||
return b;
|
||||
}
|
||||
|
||||
test("the PE check reads each image's own machine and refuses a tree of another architecture", () => {
|
||||
const { peMachine, machines, foreign } = require("../packaging/pe.js");
|
||||
assert.equal(peMachine(pe(0xaa64)), 0xaa64);
|
||||
assert.throws(() => peMachine(Buffer.from("#!/bin/sh\n".repeat(10))), /not a PE/);
|
||||
assert.throws(() => peMachine(pe(0x8664).subarray(0, 0x70)), /no PE signature/);
|
||||
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "reasonix-pe-"));
|
||||
try {
|
||||
assert.throws(() => foreign(dir, "arm64"), /no PE images/);
|
||||
fs.mkdirSync(path.join(dir, "resources", "bin"), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, "app.exe"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "resources", "bin", "host.exe"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "locales.pak"), "not an image");
|
||||
fs.writeFileSync(path.join(dir, "noext"), pe(0xaa64));
|
||||
assert.equal(machines(dir).length, 3);
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
assert.deepEqual(foreign(dir, "amd64").map((f) => f.file), ["app.exe", "noext", "resources/bin/host.exe"]);
|
||||
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0x8664));
|
||||
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["ffmpeg.dll"]);
|
||||
assert.throws(() => foreign(dir, "x64"), /unknown architecture/);
|
||||
fs.writeFileSync(path.join(dir, "broken.dll"), "text, not an image");
|
||||
const broken = foreign(dir, "arm64").find((f) => f.file === "broken.dll");
|
||||
assert.match(broken.error, /not a PE/);
|
||||
fs.rmSync(path.join(dir, "broken.dll"));
|
||||
fs.writeFileSync(path.join(dir, "ffmpeg.dll"), pe(0xaa64));
|
||||
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0x14c));
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
fs.writeFileSync(path.join(dir, "Uninstall Reasonix Studio.exe"), pe(0x14c));
|
||||
assert.deepEqual(foreign(dir, "arm64"), []);
|
||||
fs.writeFileSync(path.join(dir, "resources", "elevate.exe"), pe(0xaa64));
|
||||
assert.deepEqual(foreign(dir, "arm64").map((f) => f.file), ["resources/elevate.exe"]);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
const { createPowerGuard, keepsAwake, IDLE_PASSES, SILENT_PASSES } = require("../src/powerguard.js");
|
||||
|
||||
function fakeBlocker() {
|
||||
const live = new Set();
|
||||
let next = 1;
|
||||
const calls = { start: 0, stop: 0 };
|
||||
return {
|
||||
calls,
|
||||
live,
|
||||
start(kind) {
|
||||
assert.equal(kind, "prevent-app-suspension");
|
||||
calls.start++;
|
||||
live.add(next);
|
||||
return next++;
|
||||
},
|
||||
stop(id) {
|
||||
calls.stop++;
|
||||
live.delete(id);
|
||||
},
|
||||
isStarted: (id) => live.has(id),
|
||||
};
|
||||
}
|
||||
|
||||
// answer is what the kernel says next: { panes, unknown }, null for a kernel
|
||||
// that cannot answer, or an Error to throw.
|
||||
function guardOver(state) {
|
||||
const blocker = fakeBlocker();
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: async () => {
|
||||
if (state.answer instanceof Error) throw state.answer;
|
||||
return state.answer;
|
||||
},
|
||||
enabled: () => state.enabled !== false,
|
||||
});
|
||||
return { blocker, guard };
|
||||
}
|
||||
const busy = (panes) => ({ panes, unknown: 0 });
|
||||
const idle = { panes: 0, unknown: 0 };
|
||||
async function times(n, guard) {
|
||||
for (let i = 0; i < n; i++) await guard.refresh();
|
||||
}
|
||||
|
||||
test("a running turn holds the computer awake at once, once, however many panes run", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
await guard.refresh();
|
||||
state.answer = busy(4);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.start, 1);
|
||||
});
|
||||
|
||||
test("the hold is let go only after the third idle answer in a row", async () => {
|
||||
const state = { answer: busy(2) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1, "two idle answers are not enough");
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
assert.equal(blocker.calls.stop, 1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.stop, 1);
|
||||
});
|
||||
|
||||
test("a turn showing up between idle answers starts the count again", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
state.answer = busy(1);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
});
|
||||
|
||||
test("an idle kernel never takes the hold", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: idle });
|
||||
await times(5, guard);
|
||||
assert.equal(blocker.calls.start, 0);
|
||||
});
|
||||
|
||||
test("a kernel that cannot answer, or a pane that cannot be asked, changes nothing", async () => {
|
||||
for (const unsure of [null, new Error("kernel gone"), { panes: 0, unknown: 1 }, {}]) {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = idle;
|
||||
await times(IDLE_PASSES - 1, guard);
|
||||
state.answer = unsure;
|
||||
await times(SILENT_PASSES - 1, guard);
|
||||
assert.equal(blocker.live.size, 1, `held through ${String(unsure)}`);
|
||||
assert.equal(blocker.calls.stop, 0, "silence did not count as the idle answer that was missing");
|
||||
}
|
||||
});
|
||||
|
||||
test("silence does not take the hold either", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: null });
|
||||
await times(3, guard);
|
||||
assert.equal(blocker.calls.start, 0);
|
||||
});
|
||||
|
||||
test("a hold nobody can confirm is let go after SILENT_PASSES, so it cannot leak", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.answer = null;
|
||||
await times(SILENT_PASSES, guard);
|
||||
assert.equal(blocker.live.size, 0);
|
||||
});
|
||||
|
||||
test("turning the setting off lets go at once and keeps it off while turns run", async () => {
|
||||
const state = { answer: busy(1) };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
state.enabled = false;
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.calls.start, 1);
|
||||
state.enabled = true;
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("a block the system dropped is taken again while work continues", async () => {
|
||||
const { blocker, guard } = guardOver({ answer: busy(1) });
|
||||
await guard.refresh();
|
||||
blocker.live.clear();
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
assert.equal(blocker.calls.start, 2);
|
||||
});
|
||||
|
||||
test("an answer that arrives after a newer one does not overturn it", async () => {
|
||||
const blocker = fakeBlocker();
|
||||
const pending = [];
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: () => new Promise((resolve) => pending.push(resolve)),
|
||||
enabled: () => true,
|
||||
});
|
||||
const slow = guard.refresh();
|
||||
const fresh = guard.refresh();
|
||||
pending[1](busy(2));
|
||||
await fresh;
|
||||
pending[0](idle);
|
||||
await slow;
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("a refresh asked for the moment a turn is submitted takes the hold without waiting for the poll", async () => {
|
||||
const state = { answer: idle };
|
||||
const { blocker, guard } = guardOver(state);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
state.answer = busy(1);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
});
|
||||
|
||||
test("closing the guard releases the hold and stops the poll", async () => {
|
||||
const timers = [];
|
||||
const blocker = fakeBlocker();
|
||||
const guard = createPowerGuard({
|
||||
blocker,
|
||||
running: async () => busy(1),
|
||||
enabled: () => true,
|
||||
setInterval: (fn, ms) => {
|
||||
timers.push({ fn, ms, cleared: false });
|
||||
return timers.at(-1);
|
||||
},
|
||||
clearInterval: (t) => (t.cleared = true),
|
||||
});
|
||||
guard.begin();
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 1);
|
||||
guard.close();
|
||||
assert.equal(blocker.live.size, 0);
|
||||
assert.equal(timers.length, 1);
|
||||
assert.equal(timers[0].cleared, true);
|
||||
await guard.refresh();
|
||||
assert.equal(blocker.live.size, 0, "a closed guard takes nothing back");
|
||||
});
|
||||
|
||||
test("keeping awake is on unless this machine said off", () => {
|
||||
assert.equal(keepsAwake({}), true);
|
||||
assert.equal(keepsAwake(undefined), true);
|
||||
assert.equal(keepsAwake({ "rx-keep-awake": "on" }), true);
|
||||
assert.equal(keepsAwake({ "rx-keep-awake": "off" }), false);
|
||||
});
|
||||
|
||||
test("the host client asks the kernel how many panes run", async () => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(req.url === "/tray/running" ? 200 : 404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ panes: 3 }));
|
||||
});
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const client = new StudioHost(`http://127.0.0.1:${server.address().port}`, "t");
|
||||
assert.deepEqual(await client.trayRunning(), { panes: 3 });
|
||||
server.close();
|
||||
assert.equal(await client.trayRunning(), null);
|
||||
});
|
||||
|
||||
test("the window ground follows the stored theme and, for auto, the system", () => {
|
||||
assert.equal(groundFor({ "rx-theme": "dark" }, false), GROUND.dark);
|
||||
assert.equal(groundFor({ "rx-theme": "light" }, true), GROUND.light);
|
||||
assert.equal(groundFor({ "rx-theme": "auto" }, true), GROUND.dark);
|
||||
assert.equal(groundFor({}, false), GROUND.light);
|
||||
assert.equal(groundFor(undefined, true), GROUND.dark);
|
||||
});
|
||||
@@ -41,7 +41,9 @@
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(pair ? { code: pair } : { token: token }),
|
||||
}).then(function () {});
|
||||
}).then(function (response) {
|
||||
if (response.ok) window.location.reload();
|
||||
});
|
||||
} catch (e) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -54,13 +54,17 @@ node perf/i18n.mjs # 词表守卫:源码用到的中文 key 与英文词
|
||||
node perf/selfcheck.mjs # 守卫的守卫:往每个断言脚本塞一条必假断言,看它是否真的会红
|
||||
node perf/codes.mjs # 码的守卫:内核发的每个拒绝码,前端都要有话说
|
||||
node perf/tokens.mjs # 变量的守卫:样式里不带兜底的 var(--x),都得真有人赋过值
|
||||
node perf/tiers.mjs # 墨色分档的守卫:每档的正文/次要/淡/幽灵色不低于首次绘制的那一步,且都过 AA
|
||||
node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落在的那块底够不够 AA
|
||||
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
|
||||
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
|
||||
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
|
||||
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
|
||||
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
|
||||
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
|
||||
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
|
||||
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
|
||||
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
|
||||
```
|
||||
|
||||
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
// Many sessions streaming at once (#12232): hidden panes must not be rewritten at
|
||||
// stream rate, and N=PERF_GUARD must keep FPS_RATIO_MIN of the N=1 frame rate.
|
||||
// PERF_N=1,3,6,9 lists more rows; the verdict compares only N=1 with PERF_GUARD.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const CPU = Number(process.env.PERF_CPU ?? 4);
|
||||
const SECS = Number(process.env.PERF_SECS ?? 5);
|
||||
const TURNS = Number(process.env.PERF_TURNS ?? 40);
|
||||
const GUARD = Number(process.env.PERF_GUARD ?? 9);
|
||||
const COUNTS = (process.env.PERF_N ?? `1,${GUARD}`).split(",").map(Number);
|
||||
const WRITES_MAX = Number(process.env.PERF_WRITES_MAX ?? 4);
|
||||
const FPS_RATIO_MIN = Number(process.env.PERF_FPS_RATIO ?? 0.7);
|
||||
|
||||
const THOUGHT_CHARS = Number(process.env.PERF_THOUGHT ?? 3000);
|
||||
const THOUGHT = "先看一下这个文件的结构,再决定改哪里;要注意调用方、回放路径与错误状态。".repeat(Math.ceil(THOUGHT_CHARS / 34 / 40));
|
||||
const browser = await chromium.launch();
|
||||
const rows = [];
|
||||
const fails = [];
|
||||
|
||||
for (const n of COUNTS) {
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
if (CPU > 1) await cdp.send("Emulation.setCPUThrottlingRate", { rate: CPU });
|
||||
await cdp.send("Performance.enable");
|
||||
await page.goto(`${URL}?panes=${n}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
await page.evaluate(async (k) => {
|
||||
const yieldFrame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < k; i++) {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
window.__feed({ kind: "reasoning", text: "先看文件再决定改哪里。" });
|
||||
window.__feed({ kind: "text", text: `第 ${i} 段回答。\n\n- 要点一\n- 要点二\n\n` });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
if (i % 10 === 9) await yieldFrame();
|
||||
}
|
||||
await yieldFrame();
|
||||
}, TURNS);
|
||||
await page.waitForTimeout(500);
|
||||
await page.evaluate((t) => {
|
||||
window.__feed({ kind: "turn_started" });
|
||||
for (let i = 0; i < 40; i++) window.__feed({ kind: "reasoning", text: t });
|
||||
}, THOUGHT);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const read = async () => Object.fromEntries((await cdp.send("Performance.getMetrics")).metrics.map((m) => [m.name, m.value]));
|
||||
async function phase(kind) {
|
||||
const before = await read();
|
||||
const r = await page.evaluate(
|
||||
([k, secs]) =>
|
||||
new Promise((resolve) => {
|
||||
let frames = 0;
|
||||
let worst = 0;
|
||||
const t0 = performance.now();
|
||||
let last = t0;
|
||||
const tick = () => {
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
worst = Math.max(worst, now - last);
|
||||
last = now;
|
||||
if (now - t0 < secs * 1000) requestAnimationFrame(tick);
|
||||
};
|
||||
requestAnimationFrame(tick);
|
||||
let writes = 0;
|
||||
const mo = new MutationObserver((ms) => {
|
||||
for (const m of ms) {
|
||||
const at = m.target instanceof Element ? m.target : m.target.parentElement;
|
||||
if (at?.closest(".pane[data-off] .out")) writes++;
|
||||
}
|
||||
});
|
||||
mo.observe(document.body, { subtree: true, characterData: true, childList: true });
|
||||
const feed = setInterval(() => window.__feed({ kind: k, text: "继续往下想一小段文字," }), 33);
|
||||
setTimeout(() => {
|
||||
clearInterval(feed);
|
||||
mo.disconnect();
|
||||
resolve({ wall: performance.now() - t0, frames, worst, writes });
|
||||
}, secs * 1000);
|
||||
}),
|
||||
[kind, SECS],
|
||||
);
|
||||
const after = await read();
|
||||
const d = (key) => (after[key] ?? 0) - (before[key] ?? 0);
|
||||
return {
|
||||
N: n,
|
||||
阶段: kind === "reasoning" ? "thinking" : "answering",
|
||||
"主线程%": ((d("TaskDuration") * 1000 * 100) / r.wall).toFixed(0),
|
||||
"脚本%": ((d("ScriptDuration") * 1000 * 100) / r.wall).toFixed(0),
|
||||
"样式重算/s": (d("RecalcStyleCount") / (r.wall / 1000)).toFixed(0),
|
||||
"布局/s": (d("LayoutCount") / (r.wall / 1000)).toFixed(0),
|
||||
fps: ((r.frames / r.wall) * 1000).toFixed(1),
|
||||
"最长帧/ms": r.worst.toFixed(0),
|
||||
"后台写/s/窗格": n > 1 ? (r.writes / (r.wall / 1000) / (n - 1)).toFixed(1) : "-",
|
||||
节点: after.Nodes,
|
||||
};
|
||||
}
|
||||
const a = await phase("reasoning");
|
||||
await page.evaluate(() => window.__feed({ kind: "text", text: "开始回答。" }));
|
||||
const b = await phase("text");
|
||||
rows.push(a, b);
|
||||
await ctx.close();
|
||||
}
|
||||
await browser.close();
|
||||
console.log(`降频 ${CPU}x,每个窗格 ${TURNS} 轮历史,每阶段 ${SECS}s\n`);
|
||||
console.table(rows);
|
||||
for (const phase of ["thinking", "answering"]) {
|
||||
const one = rows.find((r) => r.N === 1 && r.阶段 === phase);
|
||||
const many = rows.find((r) => r.N === GUARD && r.阶段 === phase);
|
||||
if (!one || !many) {
|
||||
fails.push(`${phase} 缺 N=1 或 N=${GUARD} 的读数`);
|
||||
continue;
|
||||
}
|
||||
const writes = Number(many["后台写/s/窗格"]);
|
||||
const ratio = Number(many.fps) / Number(one.fps);
|
||||
console.log(`${phase}: 后台窗格每秒写 ${writes} 次(≤ ${WRITES_MAX}),N=${GUARD} 帧率 / N=1 帧率 = ${ratio.toFixed(2)}(≥ ${FPS_RATIO_MIN})`);
|
||||
if (writes > WRITES_MAX) fails.push(`${phase} 后台窗格每秒写 ${writes} 次 > ${WRITES_MAX}`);
|
||||
if (ratio < FPS_RATIO_MIN) fails.push(`${phase} 帧率比 ${ratio.toFixed(2)} < ${FPS_RATIO_MIN}`);
|
||||
}
|
||||
console.log(fails.length ? `\n失败 ${fails.length} 项:\n- ${fails.join("\n- ")}` : "\n全部通过");
|
||||
process.exit(fails.length ? 1 : 0);
|
||||
@@ -161,8 +161,9 @@ const JOBS = Number(query.get("jobs") ?? 0);
|
||||
// ?treems= holds every tree read back until that long after load, so the
|
||||
// window can be seen before the kernel has named a single folder.
|
||||
const TREE_AT = performance.now() + Number(query.get("treems") ?? 0);
|
||||
// ?panes=0 opens the window with no conversation open.
|
||||
const NO_PANES = query.get("panes") === "0";
|
||||
// ?panes=0 opens the window with no conversation open; ?panes=N opens N.
|
||||
const PANES = query.has("panes") ? Number(query.get("panes")) : 1;
|
||||
const NO_PANES = PANES === 0;
|
||||
|
||||
class BenchHub extends MockHub {
|
||||
readonly feeds = new Map<string, BenchPort>();
|
||||
@@ -174,7 +175,17 @@ class BenchHub extends MockHub {
|
||||
}
|
||||
|
||||
runtimes(): Promise<RuntimeView[]> {
|
||||
return NO_PANES ? Promise.resolve([]) : super.runtimes();
|
||||
if (NO_PANES) return Promise.resolve([]);
|
||||
if (PANES <= 1) return super.runtimes();
|
||||
return Promise.resolve(
|
||||
Array.from({ length: PANES }, (_, i) => ({
|
||||
id: `r${i + 1}`,
|
||||
base: i === 0 ? "" : `/rt/r${i + 1}`,
|
||||
root: "~/projects/DeepSeek-Reasonix",
|
||||
name: "DeepSeek-Reasonix",
|
||||
sessionPath: `/sessions/pane-${i + 1}.jsonl`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
tree(): Promise<TreeWorkspace[]> {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Ink contrast per tier and scheme. Each tier's inks must be at least as legible
|
||||
// as the default palette stepped once, and as the recorded floors below (ratios
|
||||
// on --page; --ghost shares --faint's), and every ink must clear AA on both grounds.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=1";
|
||||
const STEPS = { "--text": [3.0, 10.1], "--muted": [3.5, 7.5], "--faint": [3.5, 7.0], "--ghost": [3.5, 7.0] };
|
||||
const INKS = Object.keys(STEPS);
|
||||
const GROUNDS = ["--page", "--surface"];
|
||||
const TIERS = ["soft", "normal", "strong"];
|
||||
const FLOOR = {
|
||||
"light/soft": { "--text": 11.69, "--muted": 7.63, "--faint": 5.76 },
|
||||
"light/normal": { "--text": 14.07, "--muted": 10.22, "--faint": 7.86 },
|
||||
"light/strong": { "--text": 17.86, "--muted": 13.55, "--faint": 10.42 },
|
||||
"dark/soft": { "--text": 11.48, "--muted": 7.48, "--faint": 5.63 },
|
||||
"dark/normal": { "--text": 13.92, "--muted": 9.79, "--faint": 7.57 },
|
||||
"dark/strong": { "--text": 18.01, "--muted": 13.16, "--faint": 9.92 },
|
||||
};
|
||||
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1200, height: 800 }, locale: "zh-CN" });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
|
||||
const measured = await page.evaluate(({ STEPS, INKS, GROUNDS, TIERS }) => {
|
||||
const root = document.documentElement;
|
||||
const probe = document.createElement("div");
|
||||
document.body.append(probe);
|
||||
const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
const rgb = (css) => {
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
ctx.fillStyle = "#000";
|
||||
ctx.fillStyle = css;
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
return [...ctx.getImageData(0, 0, 1, 1).data].slice(0, 3);
|
||||
};
|
||||
const lum = (c) => {
|
||||
const [r, g, b] = c.map((v) => {
|
||||
const s = v / 255;
|
||||
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const ratio = (a, b) => {
|
||||
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
const read = (name) => {
|
||||
probe.style.color = `var(${name})`;
|
||||
return getComputedStyle(probe).color;
|
||||
};
|
||||
const out = {};
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const base = Object.fromEntries(INKS.map((n) => [n, read(n === "--ghost" ? "--faint" : n)]));
|
||||
const grounds = Object.fromEntries(GROUNDS.map((g) => [g, rgb(read(g))]));
|
||||
for (const tier of TIERS) {
|
||||
const k = TIERS.indexOf(tier);
|
||||
root.dataset.contrast = tier;
|
||||
for (const ink of INKS) {
|
||||
const by = k === 0 ? 0 : STEPS[ink][k - 1];
|
||||
probe.style.color = by ? `oklch(from ${base[ink]} calc(l ${scheme === "light" ? "-" : "+"} ${(by / 100).toFixed(3)}) c h)` : base[ink];
|
||||
const ref = rgb(getComputedStyle(probe).color);
|
||||
const got = rgb(read(ink));
|
||||
for (const g of GROUNDS) out[`${scheme}/${tier}/${ink}/${g}`] = { ref: ratio(ref, grounds[g]), got: ratio(got, grounds[g]) };
|
||||
}
|
||||
}
|
||||
}
|
||||
probe.remove();
|
||||
return out;
|
||||
}, { STEPS, INKS, GROUNDS, TIERS });
|
||||
|
||||
for (const [key, { ref, got }] of Object.entries(measured)) {
|
||||
const [scheme, tier, ink, ground] = key.split("/");
|
||||
const floor = ground === "--page" ? (FLOOR[`${scheme}/${tier}`][ink === "--ghost" ? "--faint" : ink] ?? 0) : 0;
|
||||
check(`${key} not below the stepped palette or its floor`, got >= Math.max(ref, floor) - 0.005, `${got.toFixed(2)} vs ${Math.max(ref, floor).toFixed(2)}`);
|
||||
check(`${key} clears AA`, got >= 4.5, got.toFixed(2));
|
||||
}
|
||||
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
await page.emulateMedia({ colorScheme: scheme, contrast: "more" });
|
||||
const same = await page.evaluate((scheme) => {
|
||||
const root = document.documentElement;
|
||||
root.dataset.theme = scheme;
|
||||
delete root.dataset.contrast;
|
||||
const p = document.createElement("div");
|
||||
document.body.append(p);
|
||||
const c = (n) => ((p.style.color = `var(${n})`), getComputedStyle(p).color);
|
||||
const r = [c("--faint"), c("--ghost")];
|
||||
p.remove();
|
||||
return r;
|
||||
}, scheme);
|
||||
check(`${scheme} under prefers-contrast: more keeps ghost on faint`, same[0] === same[1], same.join(" / "));
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nevery tier holds");
|
||||
@@ -0,0 +1,99 @@
|
||||
// Composer control tooltips: the box must hold its own text, must not sit under a
|
||||
// dimmed ancestor, and must stay readable. All three are layout/paint questions jsdom cannot answer.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const BOX = 'textarea[role="combobox"]';
|
||||
const fails = [];
|
||||
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
// Controls are found by structure, never by label: the label is translated,
|
||||
// so a lookup by its wording matches one language and skips the other.
|
||||
const CONTROLS = { "prompt.refine": '[data-action="prompt.refine"]', attach: "button.attach" };
|
||||
|
||||
const measure = (page, selector) =>
|
||||
page.evaluate((selector) => {
|
||||
const button = document.querySelector(`.compose ${selector}`);
|
||||
const tip = button?.querySelector(".studio-control-tip");
|
||||
if (!button || !tip) return null;
|
||||
let opacity = 1;
|
||||
for (let el = tip; el; el = el.parentElement) opacity *= parseFloat(getComputedStyle(el).opacity);
|
||||
const ink = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
|
||||
const rgba = (css) => {
|
||||
ink.clearRect(0, 0, 1, 1);
|
||||
ink.fillStyle = "#000";
|
||||
ink.fillStyle = css;
|
||||
ink.fillRect(0, 0, 1, 1);
|
||||
const [r, g, b, a] = ink.getImageData(0, 0, 1, 1).data;
|
||||
return [r, g, b, a / 255];
|
||||
};
|
||||
const lum = ([r, g, b]) => {
|
||||
const f = (c) => ((c /= 255) <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b);
|
||||
};
|
||||
const over = (top, bottom) => {
|
||||
const a = top[3];
|
||||
return [0, 1, 2].map((i) => top[i] * a + bottom[i] * (1 - a)).concat(1);
|
||||
};
|
||||
const bg = rgba(getComputedStyle(tip).backgroundColor);
|
||||
const ratios = [...tip.children].map((child) => {
|
||||
const fg = over(rgba(getComputedStyle(child).color), bg);
|
||||
const [hi, lo] = [lum(fg), lum(bg)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
});
|
||||
const box = tip.getBoundingClientRect();
|
||||
const spill = [...tip.children].map((child) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(child);
|
||||
return Math.max(0, range.getBoundingClientRect().right - box.right);
|
||||
});
|
||||
return {
|
||||
opacity,
|
||||
visibility: getComputedStyle(tip).visibility,
|
||||
minRatio: Math.min(...ratios),
|
||||
spill: Math.max(...spill),
|
||||
scroll: tip.scrollWidth - tip.clientWidth,
|
||||
left: box.left,
|
||||
right: box.right,
|
||||
width: box.width,
|
||||
viewport: innerWidth,
|
||||
disabled: button.disabled,
|
||||
};
|
||||
}, selector);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const lang of ["zh", "en"]) {
|
||||
for (const width of [1440, 420]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
page.setDefaultTimeout(8000);
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(`${BASE}?pref=${lang}&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
for (const [text, control] of [["", "prompt.refine"], ["1111", "prompt.refine"], ["", "attach"]]) {
|
||||
await page.fill(BOX, text);
|
||||
const tag = `${scheme}/${lang}/${width}px/${control}/${text ? "有字" : "空"}`;
|
||||
if (!(await page.$(`.compose ${CONTROLS[control]}`))) { check(`${tag}:找得到按钮`, false); continue; }
|
||||
await page.hover(`.compose ${CONTROLS[control]}`, { force: true });
|
||||
await page.waitForTimeout(250);
|
||||
const m = await measure(page, CONTROLS[control]);
|
||||
if (!m) { check(`${tag}:找得到提示`, false); continue; }
|
||||
check(`${tag}:提示不在半透明的祖先之下`, m.opacity === 1 && m.visibility === "visible", `有效不透明度 ${m.opacity.toFixed(2)}`);
|
||||
check(`${tag}:文字不溢出提示框`, m.spill <= 0.5 && m.scroll <= 0, `溢出 ${m.spill.toFixed(1)}px,scroll ${m.scroll}px`);
|
||||
check(`${tag}:提示框留在窗口内`, m.left >= 0 && m.right <= m.viewport, `${Math.round(m.left)}–${Math.round(m.right)} / ${m.viewport}`);
|
||||
check(`${tag}:正文对比度达 AA`, m.minRatio >= 4.5, `${m.minRatio.toFixed(2)}:1`);
|
||||
}
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} 项不合格`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
|
||||
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
|
||||
//
|
||||
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
|
||||
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
|
||||
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
|
||||
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const STEP = Number(process.env.WHEEL_STEP ?? 100);
|
||||
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
|
||||
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
|
||||
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
|
||||
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
|
||||
const page = await ctx.newPage();
|
||||
const findings = [];
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (e) => pageErrors.push("page error: " + e.message));
|
||||
|
||||
await page.goto(PAGE + (PAGE.includes("?") ? "&" : "?") + "queue=1", { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app", { timeout: 15000 });
|
||||
await page.waitForTimeout(700);
|
||||
|
||||
const code = "```go\n" + Array.from({ length: 40 }, (_, i) => `func f${i}(x int) int { return x*${i} + 1 } // line ${i}`).join("\n") + "\n```\n\n";
|
||||
const table = "| a | b | c |\n|---|---|---|\n" + Array.from({ length: 12 }, (_, i) => `| ${i} | row ${i} | value ${i * 7} |`).join("\n") + "\n\n";
|
||||
const prose = Array.from({ length: 14 }, (_, i) => `Paragraph ${i}: ` + "the quick brown fox jumps over the lazy dog, ".repeat(9)).join("\n\n") + "\n\n";
|
||||
const reply = "# Heading\n\n" + prose + code + table + prose + code + "- one\n- two\n- three\n\n" + prose;
|
||||
|
||||
await page.evaluate(async ({ n, reply, calls }) => {
|
||||
const frame = () => new Promise((r) => requestAnimationFrame(r));
|
||||
for (let i = 0; i < n; i++) {
|
||||
window.__feed({ kind: "turn_started", authoredTurn: i + 1, msgIndex: i * 2, text: `Question ${i}` });
|
||||
for (let k = 0; k < (i === 0 ? calls : 6); k++) {
|
||||
const id = `t${i}-${k}`;
|
||||
const args = JSON.stringify({ path: `pkg/f${k}.go` });
|
||||
window.__feed({ kind: "tool_dispatch", tool: { id, name: "edit_file", args } });
|
||||
window.__feed({ kind: "tool_result", tool: { id, name: "edit_file", args, output: "ok", durationMs: 210, added: 4, removed: 2 } });
|
||||
}
|
||||
window.__feed({ kind: "text", text: reply });
|
||||
window.__feed({ kind: "message" });
|
||||
window.__feed({ kind: "turn_done" });
|
||||
await frame();
|
||||
}
|
||||
await frame();
|
||||
}, { n: TURNS, reply, calls: CALLS });
|
||||
await page.waitForTimeout(800);
|
||||
|
||||
const flow = page.locator('[data-pane="flow"]').first();
|
||||
const box = await flow.boundingBox();
|
||||
const cdp = await ctx.newCDPSession(page);
|
||||
const read = () => flow.evaluate((el) => ({ top: el.scrollTop, h: el.scrollHeight }));
|
||||
|
||||
const start = await read();
|
||||
const rows = [];
|
||||
let prev = start;
|
||||
for (let i = 0; i < 400; i++) {
|
||||
await cdp.send("Input.dispatchMouseEvent", { type: "mouseWheel", x: box.x + box.width / 2, y: box.y + box.height / 2, deltaX: 0, deltaY: -STEP });
|
||||
await page.waitForTimeout(WAIT);
|
||||
const p = await read();
|
||||
rows.push({ i, d: p.top - prev.top, ...p });
|
||||
prev = p;
|
||||
if (p.top <= 0) break;
|
||||
}
|
||||
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : STRICT ? "FAIL" : "NOTE"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) findings.push(name);
|
||||
};
|
||||
const down = rows.filter((r) => r.d > 2);
|
||||
const jump = rows.filter((r) => r.d < -STEP * 1.5);
|
||||
const minH = Math.min(start.h, ...rows.map((r) => r.h));
|
||||
console.log(`start top=${start.top} scrollHeight=${start.h}; ${rows.length} wheel steps; scrollHeight min=${minH} end=${prev.h}`);
|
||||
check("never moves down while the wheel goes up", down.length === 0, down.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("no step exceeds the wheel", jump.length === 0, jump.slice(0, 6).map((r) => `#${r.i}:${r.d}`).join(" "));
|
||||
check("scrollHeight does not shrink", minH >= start.h - 4, `${start.h} -> ${minH}`);
|
||||
|
||||
await browser.close();
|
||||
for (const e of pageErrors) console.log(e);
|
||||
process.exit(pageErrors.length || (STRICT && findings.length) ? 1 : 0);
|
||||
@@ -52,9 +52,12 @@ export const ACTIONS: UIAction[] = [
|
||||
// Completion verification is adaptive now, rather than a user-facing preset.
|
||||
// The legacy endpoint remains in the port for older clients and sessions,
|
||||
// but it is intentionally not advertised as an interface action.
|
||||
{ id: "chart.scroll", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
|
||||
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -67,6 +70,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// The way out of a window whose assets an update replaced underneath it.
|
||||
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "transcript.fold-preview", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -195,6 +199,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.link", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.focus", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.expand", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "backup.category", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "backup.label", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -319,6 +324,8 @@ export const ACTIONS: UIAction[] = [
|
||||
// Asking a machine whether it answers. Nothing on either side changes, and
|
||||
// asking twice is the point when the first answer was "not yet".
|
||||
{ id: "remote.probe", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "remote.hide", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.enable", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "remote.read", kind: "repeatable", target: "entity", proof: "authority-effect" },
|
||||
{ id: "workspace.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.add-cancel", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -337,6 +344,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "extensions.submit", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "provider.probe", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.draft", kind: "interaction", target: "none", proof: "interaction" },
|
||||
{ id: "provider.discard-edits", kind: "destructive", target: "none", proof: "interaction" },
|
||||
{ id: "provider.revert", kind: "interaction", target: "none", proof: "interaction" },
|
||||
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
|
||||
@@ -346,6 +356,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "provider.rename", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "provider.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "roles.model", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "roles.override.clear", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "permissions.rule-level", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "sandbox.workspace-root", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Where compaction starts. Declaring it rebuilds the runtime, which is why
|
||||
@@ -380,6 +391,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.restart", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "versions.later", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-retry", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "versions.notes-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import HTML from "../index.html?raw";
|
||||
|
||||
const script = new DOMParser().parseFromString(HTML, "text/html").querySelector("script")!.textContent!;
|
||||
|
||||
function boot(fragment: string, path = "/", search = "") {
|
||||
let resolve!: (response: { ok: boolean }) => void;
|
||||
let reject!: (error: Error) => void;
|
||||
const response = new Promise<{ ok: boolean }>((yes, no) => { resolve = yes; reject = no; });
|
||||
const fetch = vi.fn((_url: string, _options?: unknown) => response);
|
||||
const reload = vi.fn();
|
||||
const replaceState = vi.fn();
|
||||
const context = {
|
||||
window: null as unknown,
|
||||
__rxAuth: undefined as Promise<void> | undefined,
|
||||
fetch,
|
||||
location: { hash: fragment, pathname: path, search, reload },
|
||||
history: { replaceState },
|
||||
localStorage: { getItem: () => null },
|
||||
document: { documentElement: { dataset: {}, lang: "" } },
|
||||
navigator: { language: "en" },
|
||||
performance: { mark: vi.fn() },
|
||||
URLSearchParams,
|
||||
};
|
||||
context.window = context;
|
||||
new Function(...Object.keys(context), script)(...Object.values(context));
|
||||
return { context, fetch, reload, replaceState, resolve, reject };
|
||||
}
|
||||
|
||||
describe("credential bootstrap before native asset loading", () => {
|
||||
it.each([
|
||||
["token", "#token=launch-secret", "/auth/token", { token: "launch-secret" }],
|
||||
["pair", "#pair=one-time-code", "/pair", { code: "one-time-code" }],
|
||||
])("reloads after the %s cookie is established", async (_kind, fragment, route, body) => {
|
||||
const page = boot(fragment);
|
||||
expect(page.fetch).toHaveBeenCalledWith(route, {
|
||||
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
||||
});
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
page.resolve({ ok: true });
|
||||
await page.context.__rxAuth;
|
||||
expect(page.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("preserves the session path, query, and unrelated fragment before reloading", async () => {
|
||||
const page = boot("#tab=extensions&token=secret", "/sessions/session-123", "?layout=wide");
|
||||
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/sessions/session-123?layout=wide#tab=extensions");
|
||||
expect(page.replaceState.mock.calls[0][2]).not.toContain("secret");
|
||||
page.resolve({ ok: true });
|
||||
await page.context.__rxAuth;
|
||||
expect(page.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("consumes pairing once when both credentials are present", async () => {
|
||||
const page = boot("#token=secret&pair=once");
|
||||
expect(page.fetch).toHaveBeenCalledWith("/pair", expect.objectContaining({ body: JSON.stringify({ code: "once" }) }));
|
||||
expect(page.replaceState).toHaveBeenCalledWith(null, "", "/");
|
||||
page.resolve({ ok: true });
|
||||
await page.context.__rxAuth;
|
||||
expect(page.fetch).toHaveBeenCalledOnce();
|
||||
expect(page.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps application fetches behind the existing authentication promise", async () => {
|
||||
const page = boot("#token=secret");
|
||||
const application = page.context.fetch("/runtimes");
|
||||
expect(page.fetch).toHaveBeenCalledOnce();
|
||||
page.resolve({ ok: true });
|
||||
await application;
|
||||
expect(page.fetch).toHaveBeenLastCalledWith("/runtimes");
|
||||
expect(page.reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["", "#tab=extensions"])("does not reload a page without a credential (%s)", async (fragment) => {
|
||||
const page = boot(fragment);
|
||||
await page.context.__rxAuth;
|
||||
expect(page.fetch).not.toHaveBeenCalled();
|
||||
expect(page.replaceState).not.toHaveBeenCalled();
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["#token=wrong", "#pair=expired"])("does not reload a refused exchange (%s)", async (fragment) => {
|
||||
const page = boot(fragment);
|
||||
page.resolve({ ok: false });
|
||||
await page.context.__rxAuth;
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reload when the exchange cannot reach the server", async () => {
|
||||
const page = boot("#token=secret");
|
||||
const failure = new Error("network unavailable");
|
||||
page.reject(failure);
|
||||
await expect(page.context.__rxAuth).rejects.toBe(failure);
|
||||
expect(page.reload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@
|
||||
// shows only that nothing folded, never a guess at the cause.
|
||||
export const FOLD_WHY: Record<string, string> = {
|
||||
summary_failed: "生成摘要的请求失败了",
|
||||
summary_timeout: "生成摘要的请求超时了",
|
||||
summary_timeout: "生成摘要的请求停滞了:连续 6 分钟没有任何输出",
|
||||
summary_ceiling: "生成摘要的请求运行满 30 分钟仍未完成,已停止",
|
||||
summary_truncated: "摘要在输出上限处被截断,没有采用",
|
||||
summary_not_digest: "摘要模型没有按要求的标题给出摘要,没有采用",
|
||||
summary_input_too_large: "待折叠的内容缩减后仍超过单次摘要请求的容量",
|
||||
hook_refused: "扩展拒绝了这次折叠",
|
||||
persist_failed: "折叠结果没能保存",
|
||||
|
||||
@@ -56,11 +56,27 @@ export const EN: Record<string, string> = {
|
||||
"可读取代码、查找资料、运行工具并修改文件。完整执行过程可在「轨迹」中查看。":
|
||||
"It can read code, research, run tools, and edit files. The full run stays visible in the Trajectory.",
|
||||
"等待回包 {secs}s": "Waiting for a response · {secs}s",
|
||||
"连接在响应头前断了,重试 {attempt}/{max} · {secs}s":
|
||||
"Connection dropped before the headers · retry {attempt}/{max} · {secs}s",
|
||||
"回包写到一半断了,重放 {attempt}/{max} · {secs}s":
|
||||
"The response was cut mid-stream · replay {attempt}/{max} · {secs}s",
|
||||
"连接已断开,重试 {attempt}/{max} · {secs}s": "Connection dropped · retry {attempt}/{max} · {secs}s",
|
||||
"连接被中断": "Connection closed",
|
||||
"服务器没有及时响应": "The server did not answer in time",
|
||||
"服务器返回 HTTP {status}": "Server returned HTTP {status}",
|
||||
"回包停住了": "The response stalled",
|
||||
"服务器在回包中途报错": "The server reported an error mid-response",
|
||||
"回包写到一半断了": "The response was cut mid-stream",
|
||||
"请求没有成功": "The request did not succeed",
|
||||
"连接已断开": "Connection dropped",
|
||||
"{n}s 后重试 {attempt}/{max}": "next retry ({attempt}/{max}) in {n} s",
|
||||
"{n}s 后重放 {attempt}/{max}": "next replay ({attempt}/{max}) in {n} s",
|
||||
"重试 {attempt}/{max} · 等待服务器响应 {secs}s(超过 {limit}s 视为无响应)":
|
||||
"Retry {attempt}/{max} · waiting for the server to answer {secs} s ({limit} s counts as no answer)",
|
||||
"重试 {attempt}/{max} · 等待服务器响应 {secs}s": "Retry {attempt}/{max} · waiting for the server to answer {secs} s",
|
||||
"重放 {attempt}/{max} · 等待服务器响应 {secs}s": "Replay {attempt}/{max} · waiting for the server to answer {secs} s",
|
||||
"无响应超时": "No-answer timeout",
|
||||
"默认 {n}": "Default {n}",
|
||||
"秒": "s",
|
||||
"{n} 秒无响应超时": "{n} s no-answer timeout",
|
||||
"等待响应头、以及回包过程中无新内容超过这个时间,就按连接中断处理并重试。本地模型处理长上下文时可能需要调大。":
|
||||
"If the server sends nothing for this long, while waiting for the response headers or in the middle of a reply, the call is treated as dropped and retried. A local model working through a long context may need it raised.",
|
||||
"须是 {min} 到 {max} 之间的整数秒;留空使用默认值。": "Enter a whole number of seconds from {min} to {max}; leave it empty for the default.",
|
||||
"Agent": "Agent",
|
||||
"思考中…": "Thinking…",
|
||||
"思考中 {secs} 秒": "Thinking · {secs}s",
|
||||
@@ -107,6 +123,7 @@ export const EN: Record<string, string> = {
|
||||
"正在处理…": "Working…",
|
||||
"已完成": "Done",
|
||||
"已中断": "Interrupted",
|
||||
"未执行": "Not run",
|
||||
"已停止": "Stopped",
|
||||
"请求未能送达内核(HTTP {status})": "That request never reached the kernel (HTTP {status})",
|
||||
"移动 {id} 的界面": "Move {id}'s view",
|
||||
@@ -152,7 +169,7 @@ export const EN: Record<string, string> = {
|
||||
"界面大小": "Interface size",
|
||||
"正文字号": "Body text size",
|
||||
"尚未派出": "None dispatched",
|
||||
"{n} 并行": "{n} in parallel",
|
||||
"运行中 {live} / 共 {total}": "{live} running / {total} total",
|
||||
"已交付": "Handed back",
|
||||
"待审改动": "Pending changes",
|
||||
"改动 · 已放行": "Changes · auto-approved",
|
||||
@@ -185,7 +202,6 @@ export const EN: Record<string, string> = {
|
||||
"候选要做的任务": "The attempts' task",
|
||||
"这次批准的效力超出这一次调用本身。": "This approval covers more than this one call.",
|
||||
"已处理": "Handled",
|
||||
"本会话不再询问": "Allow for this session",
|
||||
"切换全部放行…": "Switch to full access…",
|
||||
"确认切换全部放行": "Confirm full access",
|
||||
"全部放行会跳过后续工具确认": "Full access skips future tool confirmations",
|
||||
@@ -449,8 +465,8 @@ export const EN: Record<string, string> = {
|
||||
"模型": "Models",
|
||||
"已启用 {on}/{all}": "{on} of {all} enabled",
|
||||
"搜索或输入完整模型 ID…": "Search or enter an exact model ID…",
|
||||
"目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;验证会发送一次最小请求,可能产生少量 Token 费用。":
|
||||
"The catalog is for discovery, not a whitelist. Unlisted models keep their exact ID; verification sends one minimal request and may use a small number of tokens.",
|
||||
"目录只用于发现,不是白名单。未列出的模型会按原始 ID 保存;「测试已启用模型」会给每个已勾选的模型各发送一次小请求,可能产生少量 Token 费用。":
|
||||
"The catalog is for discovery, not a whitelist. Unlisted models keep their exact ID; Test enabled models sends one small request per ticked model and may use a small number of tokens.",
|
||||
"搜索模型名称;列表中没有的,可直接输入名称":
|
||||
"Search the list — or type a name that is not on it",
|
||||
"搜索或添加模型": "Search or add a model",
|
||||
@@ -548,14 +564,16 @@ export const EN: Record<string, string> = {
|
||||
"测试连接": "Test it",
|
||||
"保存失败": "Save failed",
|
||||
"已保存,尚未生效": "Saved, not applied yet",
|
||||
"刷新模型目录失败": "Could not refresh the model catalog",
|
||||
"读取模型列表失败": "Could not read the model list",
|
||||
"可以直接输入完整模型 ID 添加。": "You can also type the full model ID to add it.",
|
||||
"重新问一次有哪些模型": "Ask for the model list again",
|
||||
"刷新模型目录": "Refresh model catalog",
|
||||
"正在刷新…": "Refreshing…",
|
||||
"从服务商读取可用模型": "Read available models from provider",
|
||||
"没找到?从服务商读取可用模型": "Not listed? Read available models from provider",
|
||||
"正在从服务商读取…": "Reading from provider…",
|
||||
"发现 {n} 个新模型。": "Found {n} new models. ",
|
||||
"有 {n} 个已配置模型本次未返回,已为你保留。":
|
||||
"{n} configured models were not returned this time and have been preserved.",
|
||||
"重新向该端点获取模型列表,适用于端点新增或下架模型之后": "Ask this endpoint for its model list again — use it after the endpoint adds or removes models",
|
||||
"用已保存或刚填的密钥向服务商读取模型列表,可直接勾选;服务商新增或下架模型后也一样": "Read the model list from the provider with the saved or newly entered key, then tick what you want — also after the provider adds or removes models",
|
||||
"探测结果如下。均为推断值,如有不符请直接修改。": "Here is what was probed. All of it is a guess; correct anything wrong.",
|
||||
"两种接入方式都能返回模型列表,仅凭列表无法区分;两者的聊天入口路径通常不同,选错会导致聊天报错。如需同时使用,请再添加一次并选择另一种。":
|
||||
"Both access methods answer with a model list, and the lists do not tell them apart; their chat endpoints usually sit on different paths, so the wrong choice makes chat fail. To use both, add the source again and pick the other one.",
|
||||
@@ -616,10 +634,10 @@ export const EN: Record<string, string> = {
|
||||
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
|
||||
"即将执行": "About to act",
|
||||
"允许这一次。": "Allowed once.",
|
||||
"本会话不再询问此类操作。": "Will not ask again this session for this kind.",
|
||||
"已允许:本会话内此类操作不再询问。": "Allowed: this kind will not ask again this session.",
|
||||
"已拒绝。": "Denied.",
|
||||
"已保存为规则。": "Recorded as a rule.",
|
||||
"已写入配置,后续会话也不再询问此类操作。": "Written to the config; later sessions will not ask for this kind either.",
|
||||
"已允许:已保存为规则。": "Allowed: recorded as a rule.",
|
||||
"已写入配置,后续会话中此类操作也始终允许,不再询问。": "Written to the config; later sessions will also always allow this kind without asking.",
|
||||
"正在提交…": "Submitting…",
|
||||
"已在其他窗口处理。": "Handled in another window.",
|
||||
"请以最新运行状态为准。": "Refer to the latest run state.",
|
||||
@@ -719,6 +737,12 @@ export const EN: Record<string, string> = {
|
||||
"无法连接版本目录": "Cannot reach the version index",
|
||||
"已固定": "Pinned",
|
||||
"固定在这里": "Pin here",
|
||||
"更新内容": "What's new",
|
||||
"收起更新内容": "Hide what's new",
|
||||
"{v} 的更新内容": "What's new in {v}",
|
||||
"正在读取更新内容…": "Loading what's new…",
|
||||
"更新内容读取失败": "What's new could not be read",
|
||||
"在 GitHub 查看": "View on GitHub",
|
||||
|
||||
// ── 账号 ─────────────────────────────────────────────────────────
|
||||
"正在检查登录状态…": "Checking sign-in status…",
|
||||
@@ -800,6 +824,8 @@ export const EN: Record<string, string> = {
|
||||
"无法建立安全连接。这个网站可能只支持 http,或者要求客户端证书。": "A secure connection could not be made. The site may serve only http, or require a client certificate.",
|
||||
"连不上系统代理。内置浏览器使用系统的代理设置,而不是 Reasonix 设置里的网络代理。": "The system proxy could not be reached. The built-in browser uses the operating system's proxy settings, not the network proxy in Reasonix settings.",
|
||||
"找不到这个地址。内网域名需要连上公司网络或 VPN 才能解析。": "This address could not be found. Intranet names resolve only on the company network or VPN.",
|
||||
"找不到这个文件。请确认路径正确,文件没有被移动或删除。": "This file could not be found. Check the path, and that the file has not been moved or deleted.",
|
||||
"没有权限读取这个文件。": "You do not have permission to read this file.",
|
||||
"对方拒绝了连接。请确认端口正确、服务已经启动。": "The connection was refused. Check the port and that the service is running.",
|
||||
"连接超时或地址不可达。请确认已连上公司网络或 VPN。": "The connection timed out or the address is unreachable. Check that you are on the company network or VPN.",
|
||||
"登录没有通过,请重试并检查用户名和密码。": "Sign-in failed. Check the username and password and try again.",
|
||||
@@ -905,6 +931,7 @@ export const EN: Record<string, string> = {
|
||||
"增量数据下载失败,本次改为下载完整安装包。": "The changed parts could not be downloaded, so this update downloads the full package.",
|
||||
"增量数据下载太慢,本次改为下载完整安装包。": "The changed parts were downloading too slowly, so this update downloads the full package.",
|
||||
"增量数据未通过校验,本次改为下载完整安装包。": "The changed parts failed verification, so this update downloads the full package.",
|
||||
"这次版本跨度较大,增量数据比完整安装包还多,本次改为下载完整安装包。": "This jump spans many versions and the changed parts add up to more than the full package, so this update downloads the full package.",
|
||||
"无法写入增量更新文件,本次改为下载完整安装包。": "The changed parts could not be written to disk, so this update downloads the full package.",
|
||||
"增量更新不可用,本次改为下载完整安装包。": "Updating only the changed parts is unavailable, so this update downloads the full package.",
|
||||
"校验签名…": "Verifying the signature…",
|
||||
@@ -947,6 +974,8 @@ export const EN: Record<string, string> = {
|
||||
"关闭查找": "Close find",
|
||||
"回合结束时给出回执": "End a turn with a receipt",
|
||||
"列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
|
||||
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
|
||||
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
|
||||
"改写这条消息": "Rewrite this message",
|
||||
"改写这条消息并重新发送": "Rewrite this message and send it again",
|
||||
"✎ 改写": "✎ Edit",
|
||||
@@ -961,6 +990,21 @@ export const EN: Record<string, string> = {
|
||||
// ── 通用动作 ─────────────────────────────────────────────────────
|
||||
"取消": "Cancel",
|
||||
"保存": "Save",
|
||||
"已保存": "Saved",
|
||||
"没有更改": "No changes",
|
||||
"有未保存的更改": "Unsaved changes",
|
||||
"这个服务有未保存的更改": "This service has unsaved changes",
|
||||
"离开后这些更改会丢失。": "They will be lost if you leave.",
|
||||
"放弃更改并离开": "Discard changes and leave",
|
||||
"保留编辑": "Keep editing",
|
||||
"已停止启动新的验证:草稿已改动": "No further checks started: the draft changed",
|
||||
"测试已启用模型({n})": "Test enabled models ({n})",
|
||||
"{n} 个验证中": "{n} verifying",
|
||||
"没有发现新模型": "No new models found",
|
||||
"{n} 个可用": "{n} available",
|
||||
"{n} 个不可用": "{n} unavailable",
|
||||
"{n} 个无法确定": "{n} undetermined",
|
||||
"{n} 个未验证": "{n} not verified",
|
||||
"关闭": "Close",
|
||||
"确定": "OK",
|
||||
"重试": "Retry",
|
||||
@@ -1063,6 +1107,7 @@ export const EN: Record<string, string> = {
|
||||
"查看上下文与压缩": "View context and compaction",
|
||||
"查看本轮费用": "View turn cost",
|
||||
"查看钱包余额": "View wallet balance",
|
||||
"余额不足": "Balance is low",
|
||||
"隐藏金额": "Hide amounts",
|
||||
"显示金额": "Show amounts",
|
||||
"收起运行统计": "Collapse run statistics",
|
||||
@@ -1298,8 +1343,22 @@ export const EN: Record<string, string> = {
|
||||
"同一段内容被逐字重复": "The same block is being repeated verbatim",
|
||||
"任务已按设置暂停:模型卡在重复输出同一段文字": "Paused by your setting: the model is stuck repeating the same text",
|
||||
"等待你的输入": "Waiting for you",
|
||||
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
|
||||
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
|
||||
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
|
||||
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
|
||||
"上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。": "Context at {percent}% of the compaction threshold — the model was told it has about {remaining} tokens of room left.",
|
||||
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
|
||||
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
|
||||
"打开「工具与集成」": "Open Tools and integrations",
|
||||
"图表": "Chart",
|
||||
"图表:{title}(可横向滚动)": "Chart: {title} (scrolls sideways)",
|
||||
"没有可画的数据": "Nothing to draw",
|
||||
"柱状图": "Bar chart",
|
||||
"折线图": "Line chart",
|
||||
"饼图": "Pie chart",
|
||||
"{kind}:{title},共 {n} 行": "{kind}: {title}, {n} rows",
|
||||
"查看数据": "View data",
|
||||
"收起数据": "Hide data",
|
||||
"图表数据:{title}": "Chart data: {title}",
|
||||
"另有 {n} 行": "and {n} more rows",
|
||||
};
|
||||
@@ -11,6 +11,9 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"疑问": "Question",
|
||||
"其他": "Other",
|
||||
"已收到": "Received",
|
||||
"维护者正在查看这份反馈,可能会回复你。现在还不能回复;维护者回复后,这里会出现回复框。":
|
||||
"A maintainer is reading this report and may reply. You cannot reply yet; once a maintainer replies, a reply box appears here.",
|
||||
"这份反馈正在审核中:维护者正在查看,可能会回复。": "This report is under review: a maintainer is reading it and may reply.",
|
||||
"已登记": "Recorded",
|
||||
"处理中": "In progress",
|
||||
"已修复": "Fixed",
|
||||
@@ -130,4 +133,36 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"{n} 项待查看": "{n} to review",
|
||||
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
|
||||
"回复已发送。": "Reply sent.",
|
||||
"反馈等级": "Feedback level",
|
||||
"升级进度": "Progress to the next level",
|
||||
"新种": "New seed",
|
||||
"萌芽": "Sprout",
|
||||
"幼苗": "Seedling",
|
||||
"小树": "Sapling",
|
||||
"繁花": "Bloom",
|
||||
"成荫": "Canopy",
|
||||
"共林": "Grove",
|
||||
"L{n} · {name}": "L{n} · {name}",
|
||||
"L{n}": "L{n}",
|
||||
"已落地 {n} 条,已是最高等级": "{n} shipped, the highest level",
|
||||
"已落地 {n} 条,再 {left} 条升到「{name}」": "{n} shipped, {left} more to reach {name}",
|
||||
"已落地 {n} 条,再 {left} 条升到 L{level}": "{n} shipped, {left} more to reach L{level}",
|
||||
"每小时最多 {h} 条反馈 · 每天 {d} 条 · 每小时 {r} 条回复": "Up to {h} reports an hour · {d} a day · {r} replies an hour",
|
||||
"你现在较高的额度保留到 {date}。": "Your current higher limits stay until {date}.",
|
||||
"较高的额度有效期至 {date},下一条落地的反馈会续上。": "Your higher limits run until {date}; the next report that ships renews them.",
|
||||
"较高的额度已经失效。等级和已落地的条数都保留,之后再有反馈落地就会续上。": "Your higher limits have lapsed. Your level and count are kept, and a later report that ships renews them.",
|
||||
"这是上次确认的等级({date}),连上反馈服务后会更新。": "This is the last confirmed level ({date}); it updates once the feedback service is reachable.",
|
||||
"等级暂时无法显示。": "Your level cannot be shown right now.",
|
||||
"这一小时的反馈次数已用完。": "You have used this hour's feedback allowance.",
|
||||
"今天的反馈次数已用完。": "You have used today's feedback allowance.",
|
||||
"这个网络这一小时的提交次数已用完。": "This network has used this hour's submission allowance.",
|
||||
"这一小时的回复次数已用完。": "You have used this hour's reply allowance.",
|
||||
"反馈通道今天的接收量已满,不是你发得太多。内容都还在。": "The feedback channel is full for today; you did not send too much. Your text is kept.",
|
||||
"反馈通道此刻很忙,不是你发得太多。内容都还在。": "The feedback channel is busy right now; you did not send too much. Your text is kept.",
|
||||
"已达到一项提交上限。": "A submission limit was reached.",
|
||||
"将在 {time} 重置。": "It resets at {time}.",
|
||||
"约 {n} 秒后重置。": "It resets in about {n} sec.",
|
||||
"约 {n} 分钟后重置。": "It resets in about {n} min.",
|
||||
"约 {n} 小时后重置。": "It resets in about {n} h.",
|
||||
"这份反馈的回复次数已到上限,不会自动重置。如有新的情况,可以另外提交一条反馈。": "This report has reached its reply limit, and it does not reset. If something new comes up, send another report.",
|
||||
};
|
||||
@@ -3,6 +3,11 @@
|
||||
// code adds one here. i18n.test.ts is what stops one from being forgotten.
|
||||
|
||||
export const EN_KERNEL: Record<string, string> = {
|
||||
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
|
||||
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
|
||||
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
|
||||
"无法连接内核,这次操作可能没有送达,重试前请先确认": "The kernel cannot be reached; the action may not have been delivered - check before retrying",
|
||||
"本界面无响应,这次操作可能没有送达内核,重试前请先确认": "This window is unresponsive; the action may not have reached the kernel - check before retrying",
|
||||
"递归删除目标受保护或超出授权范围;请使用工作区或授权目录内的字面路径": "The recursive delete target is protected or outside the granted roots; use a literal path inside the workspace or a granted directory",
|
||||
"无法确定递归删除范围;请使用字面命令名、路径和解释器内容": "The recursive delete extent is unknown; use a literal command name, path and interpreter payload",
|
||||
"请拆分命令;递归删除前只能使用字面路径切换目录,不能依赖变量赋值": "Split the call; only literal directory changes may precede a recursive delete, without relying on variable assignments",
|
||||
@@ -17,6 +22,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"尚未取得所需的写入范围,这次等待已结束": "The wait ended before the requested write claim was granted",
|
||||
"会话 {holder}({session})持有写入范围:{paths}": "Session {holder} ({session}) holds write claim: {paths}",
|
||||
"所需写入范围:{paths}": "Requested write claim: {paths}",
|
||||
"参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试": "The arguments break this tool's contract, so the call did not run; complete or correct them per the tool description and retry",
|
||||
"另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试": "Another session holds the requested write claim. This operation did not run; end the current turn and retry after the claim is released",
|
||||
"反馈通道今日已满,请明天再试": "Feedback is at capacity for today - try again tomorrow",
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
@@ -125,6 +131,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"请为该机器填写名称": "Give this machine a name",
|
||||
"请填写要连接的地址": "Say which address to dial",
|
||||
"这不是有效的端口号": "That is not a port number",
|
||||
"该主机已停用,启用后才能连接": "This machine is turned off; turn it on to connect",
|
||||
"该机器仍有 {n} 个打开的面板,请先关闭再移除": "This machine still has {n} open panes — close them before removing it",
|
||||
"{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。":
|
||||
"The host key for {host} changed. That machine may have been rebuilt \u2014 or someone may be in the middle. The record is at {file} line {line}; do not connect until you have checked.",
|
||||
@@ -162,6 +169,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
|
||||
"这台服务器不允许修改模型来源": "This server does not allow editing model sources",
|
||||
"打不开这个网页:{error}": "That page could not be opened: {error}",
|
||||
"没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到": "No usable browser was found. Install Chrome, Edge or Chromium, or set [browser] executable in the config; a new session reads it.",
|
||||
"内置浏览器没能启动,稍后再试一次": "The built-in browser could not start. Try again in a moment.",
|
||||
"内置浏览器的资料目录正被另一个浏览器占用。关掉其他 Studio 窗口或用同一资料目录的浏览器后再试": "Another browser is using the built-in browser's profile directory. Close other Studio windows or browsers on that profile and try again.",
|
||||
"通知设置没能保存:{error}": "The notification settings could not be saved: {error}",
|
||||
"这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。": "No editor such as VS Code or Cursor was found on this machine. Install one, or name its path with [desktop] editor.",
|
||||
"编辑器没能启动:{error}": "The editor could not be started: {error}",
|
||||
@@ -225,6 +235,9 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"This service lists {count} models, none of which can hold a conversation — it may only do embeddings or reranking",
|
||||
"服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试":
|
||||
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
|
||||
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
|
||||
"That address did not answer in time. Check the network or proxy, or try again shortly",
|
||||
"检查失败,没有具体原因": "The check failed and gave no reason",
|
||||
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
|
||||
"Cannot reach that address. Check the network, or whether the address has a typo",
|
||||
"该地址有响应,但不是 OpenAI 或 Anthropic 类接口。请确认是否误将网页地址复制过来":
|
||||
@@ -257,6 +270,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"HTTP 上不提供 shell 命令": "Shell commands are not served over HTTP",
|
||||
"不存在「{role}」这个角色": "There is no “{role}” role",
|
||||
"没有已配置的模型匹配「{model}」": "No configured model matches “{model}”",
|
||||
"「{key}」不在用户配置里,可能已被清除,或来自项目配置": "“{key}” is not in the user config; it may already be cleared, or come from the project config",
|
||||
"这台服务器未开放 shell 设置": "This server does not open shell settings",
|
||||
"这台服务器未开放账号登录": "This server does not open account sign-in",
|
||||
"登录账号后才能使用云备份": "Sign in to use cloud backups",
|
||||
@@ -278,13 +292,21 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
|
||||
"这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置": "This pane's default model is decided by the machine its models come from; set it from a pane on this machine",
|
||||
"本次拖入 {count} 个,最多允许 {limit} 个": "{count} dropped at once — the limit is {limit}",
|
||||
"这个文件的格式暂不支持({format})。支持的图片格式:{supported}。可以先转换格式再添加。": "This file's format is not supported ({format}). Supported image formats: {supported}. Convert it first, then add it again.",
|
||||
"这个文件超过 {limit_mb} MB 的上限,请压缩或拆分后再添加。": "This file is over the {limit_mb} MB limit. Compress or split it, then add it again.",
|
||||
"这个文件是空的(0 字节),没有可添加的内容。": "This file is empty (0 bytes), so there is nothing to add.",
|
||||
"无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。": "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage.",
|
||||
"附件未能保存到工作区的 .reasonix/attachments:{detail}。请检查该目录的写入权限和磁盘空间。": "The attachment could not be saved to .reasonix/attachments in the workspace: {detail}. Check that folder's write permission and the free disk space.",
|
||||
"该行过长,无法补全": "That line is too long to complete",
|
||||
"该连接不支持流式传输": "This connection does not support streaming",
|
||||
"服务端出现异常,与你的操作无关": "Something went wrong on this side — nothing you did is at fault",
|
||||
"上下文长度不能是负数;填 0 表示不自动压缩": "The context window cannot be negative; 0 means no automatic compaction",
|
||||
"Token 上限不能是负数": "The token limit cannot be negative",
|
||||
"最大输出 Token 不能是负数": "The maximum output tokens cannot be negative",
|
||||
"无响应超时须在 {min} 到 {max} 秒之间;留空使用默认值": "The no-answer timeout must be between {min} and {max} seconds; leave it empty for the default",
|
||||
"无法识别「{protocol}」这种思考协议": "Unrecognised reasoning protocol “{protocol}”",
|
||||
"默认档位「{level}」不在填写的档位里": "The default level “{level}” is not one of the levels listed",
|
||||
"{model} 的默认档位「{level}」不在为它选的档位里": "The default level “{level}” for {model} is not one of the levels picked for it",
|
||||
@@ -313,6 +335,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
|
||||
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
|
||||
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
|
||||
"这不是一个已发布的版本号": "That is not a published version number",
|
||||
"这个版本没有发布更新内容": "This version has no published notes",
|
||||
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
|
||||
"这个版本的更新内容超出了允许的大小,已拒绝读取": "This version's notes are larger than allowed and were not read",
|
||||
"已有一个版本切换正在进行,请等待其完成后重试":
|
||||
"A version change is already running — wait for it to finish and try again",
|
||||
"本次版本切换未能启动:{detail}": "This version change did not start: {detail}",
|
||||
|
||||
@@ -89,6 +89,8 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"无法在 {host} 上安装 reasonix —— npm、上传、下载均已尝试。请先自行在该机器上安装,再重新连接": "Nothing could install reasonix on {host} — npm, upload and download were all tried. Install one there by hand, then connect again",
|
||||
"安装到 {host} 上的 reasonix 无法运行。该目录可能挂载了 noexec,也可能传输中断": "The reasonix installed on {host} will not run. That directory may be mounted noexec, or the transfer was cut short",
|
||||
"{host} 上的 reasonix 已启动,但始终未报告端口。请查看该机器上 ~/.reasonix/remote 下的日志": "reasonix started on {host} but never reported a port. Look at the logs under ~/.reasonix/remote over there",
|
||||
"{host} 上已有一个正在运行的 reasonix serve,它的模型来源(本机代理或该机器自带的密钥)与本次连接的设置不一致。为避免打断它正在做的事,没有替换它。请把该主机的 provider 改成与它一致,或先在该机器上运行 reasonix remote serve stop 再连接": "A reasonix serve is already running on {host}, and it resolves models differently from this connection (this machine's broker versus the host's own keys). It was left alone so its work is not interrupted. Set this host's provider to match it, or run reasonix remote serve stop on that machine first and connect again",
|
||||
"{host} 上已有一个正在运行的 reasonix serve 占用着该工作区,但无法接入(令牌文件或地址不可读)。请先在该机器上结束那个进程(进程号记在该机器 .reasonix/remote 目录下的 .pid 文件里),再重新连接": "A reasonix serve is already running for this workspace on {host}, but it cannot be attached to (its token file or address is unreadable). End that process over there (its id is in the .pid file in that machine's .reasonix/remote directory), then connect again",
|
||||
"跳板机": "Jump host",
|
||||
"登录密码的环境变量名": "Env var with the password",
|
||||
"安装方式": "Installing",
|
||||
|
||||
@@ -19,6 +19,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"默认模型": "Default model",
|
||||
"当前对话和大多数任务": "This conversation and most tasks",
|
||||
"随主模型": "Follows the default",
|
||||
"「{key}」已被配置固定为 {model},这里的选择对它不起作用。": "“{key}” is pinned to {model} by configuration, so the choice here does not apply to it.",
|
||||
"改回跟随这里": "Follow this choice",
|
||||
"来自项目配置,需在项目里修改": "Set by the project config; change it there",
|
||||
"在「模型服务」添加决策来源": "Add a decision source under Model services",
|
||||
"按用途选择模型": "Models by use",
|
||||
"默认模型用于当前对话和大多数任务,其他用途默认跟随它;只有要为某件事换一个模型时才改。切换会保留对话并重建运行时,任务执行期间无法修改。":
|
||||
@@ -35,8 +38,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"添加一个模型服务后,在这里查看和修改它。": "Add a model service to see and change it here.",
|
||||
"选择一个服务查看和修改;地址、密钥与模型列表保存后生效。自定义中转站的协议与模型会在连接后自动探测。":
|
||||
"Pick a service to see and change it; the address, key and model list take effect when saved. A custom gateway's protocol and models are detected once it connects.",
|
||||
"这个端点还有 {n} 个模型不在列表里:{names}。点「刷新模型目录」把它们加进来。":
|
||||
"This endpoint has {n} more models not in the list: {names}. Use Refresh model catalog to add them.",
|
||||
"这个端点还有 {n} 个模型不在列表里:{names}。点「从服务商读取可用模型」把它们加进来。":
|
||||
"This endpoint has {n} more models not in the list: {names}. Use Read available models from provider to add them.",
|
||||
"添加来源": "Add source",
|
||||
"完成标准": "Completion standard",
|
||||
"系统根据任务风险自动决定需要的验证程度,不再要求手动选择均衡或交付。": "Reasonix chooses the needed verification level from task risk; there is no longer a Balanced or Delivery choice.",
|
||||
@@ -264,6 +267,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"当前项目的配置文件开启了它,此工作区仍会提供内置浏览器。": "This project's config file turns it on, so this workspace still has the built-in browser.",
|
||||
"当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。": "This project's config file turns it off, so this workspace has no built-in browser.",
|
||||
|
||||
"在侧栏隐藏": "Hide from the rail",
|
||||
"在侧栏显示": "Show in the rail",
|
||||
"启用这台主机": "Turn this machine on",
|
||||
"停用这台主机": "Turn this machine off",
|
||||
|
||||
"宣告做完": "Claims it is done",
|
||||
"还在做": "Still working",
|
||||
"卡住了,要你介入": "Stuck — needs you",
|
||||
@@ -412,8 +420,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"机器、工作区与会话": "Machines, workspaces and sessions",
|
||||
"{n} 个项目": "{n} projects",
|
||||
"允许这一次": "Allow once",
|
||||
"本会话都允许": "Allow for this session",
|
||||
"此类操作不再询问": "Stop asking for this kind",
|
||||
"本会话允许此类操作,不再询问": "Allow this kind for this session, stop asking",
|
||||
"始终允许此类操作,不再询问": "Always allow this kind, stop asking",
|
||||
"内核已记入会话授权,不写入磁盘。": "The kernel records it for this session only; nothing is written to disk.",
|
||||
"agent 已收到拒绝,将改用其他方式或终止。": "The agent takes the refusal and either finds another way or stops.",
|
||||
"下次同样的操作仍会请求确认。": "The same operation will ask again next time.",
|
||||
@@ -450,8 +458,12 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"折叠结果没能保存": "The folded result could not be saved",
|
||||
"压缩未完成": "Compaction did not complete",
|
||||
"生成摘要的请求失败了": "The request that writes the summary failed",
|
||||
"生成摘要的请求超时了": "The request that writes the summary timed out",
|
||||
"生成摘要的请求停滞了:连续 6 分钟没有任何输出":
|
||||
"The request that writes the summary stalled: no output for 6 minutes",
|
||||
"生成摘要的请求运行满 30 分钟仍未完成,已停止":
|
||||
"The request that writes the summary was still running after 30 minutes and was stopped",
|
||||
"摘要在输出上限处被截断,没有采用": "The summary hit the output limit and was discarded",
|
||||
"摘要模型没有按要求的标题给出摘要,没有采用": "The summarizer did not return a briefing under the required headings, so it was not used",
|
||||
"待折叠的内容缩减后仍超过单次摘要请求的容量": "The content to fold still exceeds one summary request after shortening",
|
||||
"摘要生成期间对话发生了变化,已放弃这次折叠": "The conversation changed while the summary was written, so this fold was dropped",
|
||||
"摘要没有记下这段内容里的任何一处改动,已放弃这次折叠": "The summary recorded none of this span's changes, so this fold was dropped",
|
||||
@@ -691,10 +703,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"验证连接并读取": "Check connection and read models",
|
||||
"连接可用 · 找到 {n} 个模型": "Connection works · {n} models found",
|
||||
"接口地址已补全为 {url}": "Address completed to {url}",
|
||||
"刷新模型目录时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
|
||||
"读取可用模型时,模型列表只在 {url} 下响应。聊天若能正常使用就不用改;可选:把接口地址改成它。": "The model list only answers at {url}. If chat already works, no change is needed; optionally set the address to it.",
|
||||
"启用的模型": "Enabled models",
|
||||
"目录只用于发现,不是白名单。直接输入服务商给出的原始模型 ID 即可。": "The catalogue is for discovery, not a whitelist. You can enter any exact model ID supplied by the service.",
|
||||
"阈值需至少为 1,000,并小于模型上下文窗口。": "The threshold must be at least 1,000 and below the model context window.",
|
||||
"被容量保护({p}%)限制,实际 {n} tokens": "Capped by the capacity guard ({p}%): runs at {n} tokens",
|
||||
"达到这个用量时开始整理": "Start maintenance when usage reaches this value",
|
||||
"常用阈值": "Common thresholds",
|
||||
"不要依赖接口猜测,请按模型文档填写": "Use the model documentation instead of endpoint guesses",
|
||||
@@ -754,7 +767,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"跳转": "Go",
|
||||
"当前是从源码启动的开发版,没有可以查看或切换的版本。安装版 Studio 会在这里列出可用的更新。": "This is a development build run from source, so there are no versions to show or change. An installed Studio lists its updates here.",
|
||||
"以下是 {host} 上的网络设置。本机的代理需要在打开本机会话时设置。": "These are the network settings on {host}. This machine's proxy is set while a local session is open.",
|
||||
"已记住这项授权,以后同样的操作不再询问": "Approval remembered; the same action will not ask again",
|
||||
"已记住这项授权,以后同样的操作自动允许,不再询问": "Approval remembered; the same action is allowed without asking again",
|
||||
"已有的授权规则覆盖了这项操作,无需另存": "An existing rule already covers this action; nothing new was saved",
|
||||
"授权没能保存,只在本次会话内有效": "The approval could not be saved; it holds for this session only",
|
||||
"常驻": "Always loaded",
|
||||
@@ -788,6 +801,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"搜索技能、插件、MCP 服务与主题": "Search skills, plugins, MCP servers and themes",
|
||||
"搜索社区市场": "Search the community market",
|
||||
"无法读取社区市场": "Could not read the community market",
|
||||
"显示的是缓存数据": "Showing cached data",
|
||||
"当前显示的是缓存数据,安装需要连接市场": "Showing cached data; installing needs a connection to the market",
|
||||
"无法连接社区市场,以下是 {when} 保存的内容,可能已过期。": "The community market could not be reached. This is what was saved on {when} and may be out of date.",
|
||||
"社区市场暂时无法正常回应,以下是 {when} 保存的内容,可能已过期。": "The community market is not answering properly. This is what was saved on {when} and may be out of date.",
|
||||
"没有找到匹配的包。": "No matching packages.",
|
||||
"没有找到已固定内容的包。可关闭筛选查看全部包。": "No pinned packages found. Turn off the filter to see all packages.",
|
||||
"查看全部包": "See all packages",
|
||||
@@ -868,7 +885,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"主题以插件包发布,包里只能有主题;带技能、钩子或 MCP 服务的包请按插件发布。": "A theme is published as a plugin package that holds only themes; a package with skills, hooks or MCP servers is published as a plugin.",
|
||||
"小写字母、数字、点、下划线、连字符,最多 64 个字符。": "Lowercase letters, digits, dots, underscores and hyphens, up to 64 characters.",
|
||||
"要发布这个包的新版本,请保持名称不变;改名会发布为另一个包。": "Keep this name to publish a new version of this package. Changing it publishes a different package.",
|
||||
"留空时新包为 0.1.0,更新自动加一个补丁号。": "Left empty, a new package starts at 0.1.0 and an update bumps the patch number.",
|
||||
"留空时新包为 0.1.0;更新只自动递增纯数字三段版本的补丁号,其他版本请明确填写。": "Blank starts a new package at 0.1.0. Updates auto-increment only a numeric three-part version; enter other versions explicitly.",
|
||||
"来源地址": "Source address",
|
||||
"摘要": "Summary",
|
||||
"描述": "Description",
|
||||
@@ -911,4 +928,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"倍窗口": "× window",
|
||||
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
|
||||
"无法读取无进展设置。": "Could not read the progress settings.",
|
||||
"图标栏": "Icon rail",
|
||||
"工作区栏旁的一列图标,直达用量、工具、扩展、记忆、远程、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons beside the workspace rail that opens usage, tools, extensions, memory, remote, account and settings. It is not drawn when the window is as narrow as a phone.",
|
||||
"显示图标栏": "Show the icon rail",
|
||||
"关闭后这些页面仍可从设置打开": "With it off these pages still open from settings",
|
||||
};
|
||||
@@ -36,6 +36,8 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"主导航": "Main navigation",
|
||||
"浏览器": "Browser",
|
||||
"空白页": "Blank page",
|
||||
"智能体正在查看的页面": "Page the agent is viewing",
|
||||
"只读:页面显示在运行智能体的电脑上": "Read-only: the page itself is shown on the computer running the agent",
|
||||
"后退": "Back",
|
||||
"前进": "Forward",
|
||||
"重新加载": "Reload",
|
||||
@@ -88,6 +90,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"打开": "Open",
|
||||
"在外部浏览器打开": "Open in external browser",
|
||||
"请输入有效的 http 或 https 地址": "Enter a valid http or https address",
|
||||
"不能直接打开网络共享路径。请先把文件放到本机,再输入它的本机路径。": "A network share cannot be opened directly. Copy the file to this machine and type its local path.",
|
||||
"Reasonix 内置 Browser": "Reasonix built-in Browser",
|
||||
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
|
||||
"正在打开网页…": "Opening page…",
|
||||
@@ -104,4 +107,7 @@ export const EN_WINDOW: Record<string, string> = {
|
||||
"全部关闭({n})": "Close all ({n})",
|
||||
"从列表移除「{name}」?": "Remove “{name}” from the list?",
|
||||
"删除「{name}」?": "Delete “{name}”?",
|
||||
"未读": "Unread",
|
||||
"1 个会话未读": "1 unread session",
|
||||
"{n} 个会话未读": "{n} unread sessions",
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
// The typed payload a skipped-extension notice carries as its detail, read as
|
||||
// the variables its sentence is worded from. Anything that does not parse reads
|
||||
// as no payload, so the notice falls back to the kernel's own text.
|
||||
export const EXTENSION_SKIPPED = "extension_skipped";
|
||||
|
||||
export function extensionSkippedVars(detail: string | undefined): { ext: string; point: string } | undefined {
|
||||
if (!detail) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(detail) as { extension?: unknown; point?: unknown };
|
||||
if (typeof p.extension === "string" && p.extension !== "" && typeof p.point === "string") return { ext: p.extension, point: p.point };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -31,6 +31,9 @@ export const count = (n: number) => nf({ maximumFractionDigits: 0 }).format(n);
|
||||
export const decimals = (v: number, digits: number) =>
|
||||
nf({ minimumFractionDigits: digits, maximumFractionDigits: digits }).format(v);
|
||||
|
||||
/** plain is a number kept to at most n places, with none padded on. */
|
||||
export const plain = (v: number, digits: number) => nf({ maximumFractionDigits: digits }).format(v);
|
||||
|
||||
/** tokens abbreviates a count of tokens or characters. Under ten thousand it
|
||||
* keeps one decimal and above it drops to none, so the field's width stays put
|
||||
* as a turn grows. */
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export const INBOX_RECOVERED = "inbox_recovered";
|
||||
|
||||
// The typed payload an inbox-recovered notice carries as its detail, read as the
|
||||
// variables its sentence is worded from. A detail that is not one reads as none,
|
||||
// so the notice keeps the kernel's English.
|
||||
export function inboxRecoveredVars(detail: string | undefined): { n: number } | undefined {
|
||||
if (!detail) return undefined;
|
||||
try {
|
||||
const p = JSON.parse(detail) as { count?: unknown };
|
||||
if (typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0) return { n: p.count };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { boot, STORAGE } from "./index";
|
||||
import { ACCOUNT_SIGNIN_DISABLED, PROVIDER_EDIT_DISABLED, SAVED_NOT_APPLIED, codes, reason } from "./kernel";
|
||||
import { HttpError } from "../port/port";
|
||||
import { DeliveryError, HttpError } from "../port/port";
|
||||
|
||||
// Pinned, not defaulted: with nothing stored the window follows the machine, so
|
||||
// on an English runner every assertion about a Chinese sentence would be about
|
||||
@@ -35,6 +35,31 @@ describe("what a reader is told a refusal was", () => {
|
||||
expect(english).not.toBe("fixture fallback");
|
||||
}
|
||||
});
|
||||
it("explains a serve left running instead of replaced, in both languages", () => {
|
||||
for (const code of ["remote.serve_provider_mismatch", "remote.serve_not_attachable"]) {
|
||||
const error = coded("fixture fallback", code, { host: "box" });
|
||||
localStorage.setItem(STORAGE, "zh"); boot();
|
||||
const chinese = reason(error);
|
||||
expect(chinese).toContain("box");
|
||||
expect(chinese).not.toBe("fixture fallback");
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
const english = reason(error);
|
||||
expect(english).toContain("box");
|
||||
expect(english).not.toBe(chinese);
|
||||
expect(english).not.toBe("fixture fallback");
|
||||
}
|
||||
});
|
||||
it("explains a refused network path in both languages", () => {
|
||||
for (const code of ["workspace.network_path_outside_scope", "browser.network_path"]) {
|
||||
localStorage.setItem(STORAGE, "zh"); boot();
|
||||
const chinese = reason(coded("fixture fallback", code));
|
||||
expect(chinese).not.toBe("fixture fallback");
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
const english = reason(coded("fixture fallback", code));
|
||||
expect(english).not.toBe(chinese);
|
||||
expect(english).not.toBe("fixture fallback");
|
||||
}
|
||||
});
|
||||
it("says a coded refusal in the window's own language", () => {
|
||||
expect(reason(coded("inbox item not found", "inbox.not_found"))).toBe("该条已不在待送达队列中");
|
||||
});
|
||||
@@ -79,6 +104,14 @@ describe("what a reader is told a refusal was", () => {
|
||||
expect(said).not.toContain("/skills/enabled");
|
||||
});
|
||||
|
||||
it("tells a busy kernel, an unreachable one and a stalled window apart", () => {
|
||||
const said = (["kernel_busy", "unreachable", "ui_stalled"] as const).map((f) => reason(new DeliveryError(f)));
|
||||
expect(new Set(said).size).toBe(3);
|
||||
expect(said[0]).toContain("内核繁忙");
|
||||
expect(said[1]).toContain("无法连接内核");
|
||||
expect(said[2]).toContain("本界面无响应");
|
||||
});
|
||||
|
||||
it("keeps a detailed answer the kernel had no code for", () => {
|
||||
expect(reason(new HttpError(400, "unknown skill: explore"))).toBe("unknown skill: explore");
|
||||
});
|
||||
@@ -121,3 +154,40 @@ describe("codes a caller branches on", () => {
|
||||
for (const code of SAVED_NOT_APPLIED) expect(codes[code]).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachment refusals", () => {
|
||||
const ico = () => coded("pasted data is not a supported image", "attachment.unsupported_image", { format: ".ico", type: "image/x-icon", supported: "PNG, JPEG, GIF, WebP" });
|
||||
|
||||
it("tells the reader the format, the supported ones and what to do, in Chinese", () => {
|
||||
expect(reason(ico())).toBe("这个文件的格式暂不支持(.ico)。支持的图片格式:PNG, JPEG, GIF, WebP。可以先转换格式再添加。");
|
||||
});
|
||||
|
||||
it("says the same in English", () => {
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
expect(reason(ico())).toBe("This file's format is not supported (.ico). Supported image formats: PNG, JPEG, GIF, WebP. Convert it first, then add it again.");
|
||||
});
|
||||
|
||||
it("names the limit when a file is too large, and says what an empty or unreadable one is", () => {
|
||||
expect(reason(coded("x", "attachment.too_large", { limit_mb: 10 }))).toContain("10 MB");
|
||||
expect(reason(coded("x", "attachment.empty"))).toContain("空");
|
||||
expect(reason(coded("x", "attachment.unreadable", { detail: "denied" }))).toContain("denied");
|
||||
expect(reason(coded("x", "attachment.write_failed", { detail: "disk full" }))).toContain("disk full");
|
||||
});
|
||||
|
||||
it("speaks every attachment code in both languages", () => {
|
||||
for (const code of ["attachment.unsupported_image", "attachment.too_large", "attachment.empty", "attachment.unreadable", "attachment.write_failed"]) {
|
||||
localStorage.setItem(STORAGE, "zh"); boot();
|
||||
const params = { format: ".ico", supported: "PNG", limit_mb: 25, detail: "d" };
|
||||
const chinese = reason(coded("fallback", code, params));
|
||||
localStorage.setItem(STORAGE, "en"); boot();
|
||||
const english = reason(coded("fallback", code, params));
|
||||
expect(chinese).not.toBe("fallback");
|
||||
expect(english).not.toBe("fallback");
|
||||
expect(english).not.toBe(chinese);
|
||||
}
|
||||
});
|
||||
|
||||
it("degrades an unknown code to the kernel's own words", () => {
|
||||
expect(reason(coded("kernel words", "attachment.from_the_future"))).toBe("kernel words");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { HttpError } from "../port/port";
|
||||
import { DeliveryError, HttpError } from "../port/port";
|
||||
import type { DeliveryFault } from "../port/port";
|
||||
import { t } from "./index";
|
||||
|
||||
// What the kernel says when it refuses, in the language the reader uses.
|
||||
@@ -30,6 +31,7 @@ const SAID: Record<string, string> = {
|
||||
"shell.parser_unavailable": "主机命令解析器不可用;请在主机恢复后重试",
|
||||
"shell.parser_timeout": "主机命令解析超时或被取消;请重试",
|
||||
"shell.command_line_too_long": "命令超过主机长度限制;请拆分命令或从文件读取长文本",
|
||||
"tool.arguments_invalid": "参数不符合该工具的约定,本次调用没有执行;请按工具说明补全或更正参数后重试",
|
||||
"workspace.write_conflict": "另一个会话持有所需的写入范围。本次操作未执行;请结束当前轮次,待该范围释放后再重试",
|
||||
// ── 忙:不是出错,是「现在不行」 ─────────────────────────────────
|
||||
"plan.decision_stale": "该决定已不符合当前状态:计划在你回答前已发生变更",
|
||||
@@ -46,6 +48,7 @@ const SAID: Record<string, string> = {
|
||||
"workspace.file_failed": "文件操作失败,请重试",
|
||||
"workspace.path_outside_tree": "该路径不在当前工作区内",
|
||||
"workspace.write_outside_scope": "写入目标不在工作区可写范围内。这是文件工具的写入范围,不是操作系统沙箱;要允许写入,请在 设置 → 沙箱 → 额外可写目录 中添加目标文件夹",
|
||||
"workspace.network_path_outside_scope": "该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用",
|
||||
"workspace.files_failed": "无法读取工作区文件列表",
|
||||
"workspace.file_unreadable": "该文件不是可编辑文本或超过大小限制",
|
||||
"provider.model_in_use": "该来源正在使用中,请先切换模型再删除",
|
||||
@@ -97,6 +100,7 @@ const SAID: Record<string, string> = {
|
||||
"shell.unavailable_over_http": "HTTP 上不提供 shell 命令",
|
||||
"roles.unknown": "不存在「{role}」这个角色",
|
||||
"roles.model_unknown": "没有已配置的模型匹配「{model}」",
|
||||
"roles.override_not_in_user_config": "「{key}」不在用户配置里,可能已被清除,或来自项目配置",
|
||||
"shell.editing_disabled": "这台服务器未开放 shell 设置",
|
||||
"account.signin_disabled": "这台服务器未开放账号登录",
|
||||
"backup.signed_out": "登录账号后才能使用云备份",
|
||||
@@ -118,13 +122,21 @@ const SAID: Record<string, string> = {
|
||||
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
|
||||
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
|
||||
"drop.too_many_paths": "本次拖入 {count} 个,最多允许 {limit} 个",
|
||||
"attachment.unsupported_image": "这个文件的格式暂不支持({format})。支持的图片格式:{supported}。可以先转换格式再添加。",
|
||||
"attachment.too_large": "这个文件超过 {limit_mb} MB 的上限,请压缩或拆分后再添加。",
|
||||
"attachment.empty": "这个文件是空的(0 字节),没有可添加的内容。",
|
||||
"attachment.unreadable": "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。",
|
||||
"attachment.write_failed": "附件未能保存到工作区的 .reasonix/attachments:{detail}。请检查该目录的写入权限和磁盘空间。",
|
||||
"complete.line_too_long": "该行过长,无法补全",
|
||||
"stream.unsupported": "该连接不支持流式传输",
|
||||
"internal.failed": "服务端出现异常,与你的操作无关",
|
||||
"provider.bad_context_window": "上下文长度不能是负数;填 0 表示不自动压缩",
|
||||
"provider.bad_token_limit": "Token 上限不能是负数",
|
||||
"provider.bad_max_output_tokens": "最大输出 Token 不能是负数",
|
||||
"provider.bad_idle_timeout": "无响应超时须在 {min} 到 {max} 秒之间;留空使用默认值",
|
||||
"provider.bad_reasoning_protocol": "无法识别「{protocol}」这种思考协议",
|
||||
"provider.default_effort_not_listed": "默认档位「{level}」不在填写的档位里",
|
||||
"provider.model_default_effort_not_listed": "{model} 的默认档位「{level}」不在为它选的档位里",
|
||||
@@ -145,6 +157,10 @@ const SAID: Record<string, string> = {
|
||||
// ── 来源:连接与授权 ─────────────────────────────────────────────
|
||||
"provider.editing_disabled": "这台服务器不允许修改模型来源",
|
||||
"browser.open_failed": "打不开这个网页:{error}",
|
||||
"browser.network_path": "内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问",
|
||||
"browser.engine_missing": "没有找到可用的浏览器。请安装 Chrome、Edge 或 Chromium,或在配置里用 [browser] executable 指定路径,新会话才会读到",
|
||||
"browser.engine_failed": "内置浏览器没能启动,稍后再试一次",
|
||||
"browser.profile_busy": "内置浏览器的资料目录正被另一个浏览器占用。关掉其他 Studio 窗口或用同一资料目录的浏览器后再试",
|
||||
"notifications.rejected": "通知设置没能保存:{error}",
|
||||
"editor.not_installed": "这台机器上没找到 VS Code、Cursor 这类编辑器。装一个,或在配置里用 [desktop] editor 指定路径。",
|
||||
"editor.launch_failed": "编辑器没能启动:{error}",
|
||||
@@ -200,7 +216,9 @@ const SAID: Record<string, string> = {
|
||||
"provider.probe.path_not_found": "地址可以连通,但该路径没有模型清单。多数服务的地址需以 /v1 结尾",
|
||||
"provider.probe.no_chat_models": "该服务列出了 {count} 个模型,但均不支持对话 —— 它可能仅提供向量或重排能力",
|
||||
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
|
||||
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
|
||||
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
|
||||
"provider.probe.failed": "检查失败,没有具体原因",
|
||||
"provider.probe.not_compatible": "该地址有响应,但不是 OpenAI 或 Anthropic 类接口。请确认是否误将网页地址复制过来",
|
||||
|
||||
// ── 推理强度:这个端点给不了 ─────────────────────────────────────
|
||||
@@ -255,6 +273,7 @@ const SAID: Record<string, string> = {
|
||||
"remote.name_required": "请为该机器填写名称",
|
||||
"remote.host_required": "请填写要连接的地址",
|
||||
"remote.bad_port": "这不是有效的端口号",
|
||||
"remote.disabled": "该主机已停用,启用后才能连接",
|
||||
"remote.has_open_panes": "该机器仍有 {n} 个打开的面板,请先关闭再移除",
|
||||
// 主机密钥变了没有「仍然连接」这条路:能绕过的警告等于没有警告。
|
||||
"remote.host_key_changed": "{host} 的主机密钥已变更。可能是该机器重装,也可能存在中间人。记录位于 {file} 第 {line} 行,核实前请勿连接。",
|
||||
@@ -371,6 +390,8 @@ const SAID: Record<string, string> = {
|
||||
"remote.no_install_path": "无法在 {host} 上安装 reasonix —— npm、上传、下载均已尝试。请先自行在该机器上安装,再重新连接",
|
||||
"remote.binary_not_runnable": "安装到 {host} 上的 reasonix 无法运行。该目录可能挂载了 noexec,也可能传输中断",
|
||||
"remote.serve_did_not_start": "{host} 上的 reasonix 已启动,但始终未报告端口。请查看该机器上 ~/.reasonix/remote 下的日志",
|
||||
"remote.serve_provider_mismatch": "{host} 上已有一个正在运行的 reasonix serve,它的模型来源(本机代理或该机器自带的密钥)与本次连接的设置不一致。为避免打断它正在做的事,没有替换它。请把该主机的 provider 改成与它一致,或先在该机器上运行 reasonix remote serve stop 再连接",
|
||||
"remote.serve_not_attachable": "{host} 上已有一个正在运行的 reasonix serve 占用着该工作区,但无法接入(令牌文件或地址不可读)。请先在该机器上结束那个进程(进程号记在该机器 .reasonix/remote 目录下的 .pid 文件里),再重新连接",
|
||||
"wallet.unauthorized": "该供应商拒绝了当前密钥,无法读取余额",
|
||||
"wallet.unreachable": "该供应商的余额接口无响应",
|
||||
"wallet.unreadable": "无法解析该供应商余额接口返回的内容",
|
||||
@@ -403,6 +424,10 @@ const SAID: Record<string, string> = {
|
||||
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
|
||||
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"studio.notes_bad_version": "这不是一个已发布的版本号",
|
||||
"studio.notes_absent": "这个版本没有发布更新内容",
|
||||
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
|
||||
"studio.notes_too_large": "这个版本的更新内容超出了允许的大小,已拒绝读取",
|
||||
"update.install_running": "已有一个版本切换正在进行,请等待其完成后重试",
|
||||
"update.install_rejected": "本次版本切换未能启动:{detail}",
|
||||
"update.restart_busy": "有 {n} 项任务正在运行,重启会中断它们",
|
||||
@@ -439,6 +464,7 @@ export function say(reason: Reason | null | undefined, fallback = ""): string {
|
||||
* window's language, anything else prints as itself. One call so no display
|
||||
* site has to know which kind it caught. */
|
||||
export function reason(e: unknown): string {
|
||||
if (e instanceof DeliveryError) return t(UNCONFIRMED[e.fault]);
|
||||
if (e instanceof HttpError && e.reason) return say(e.reason, e.message);
|
||||
// Nothing came back but a status: printing message here would put a path and
|
||||
// a number in front of the user. The status is the only identity there is.
|
||||
@@ -446,5 +472,11 @@ export function reason(e: unknown): string {
|
||||
return e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const UNCONFIRMED: Record<DeliveryFault, string> = {
|
||||
kernel_busy: "内核繁忙,这次操作可能没有被收到,重试前请先确认",
|
||||
unreachable: "无法连接内核,这次操作可能没有送达,重试前请先确认",
|
||||
ui_stalled: "本界面无响应,这次操作可能没有送达内核,重试前请先确认",
|
||||
};
|
||||
|
||||
/** codes is what the parity check reads. */
|
||||
export const codes = SAID;
|
||||
@@ -9,21 +9,24 @@ export const NOTICE_TEXT: Record<string, string> = {
|
||||
workspace_lease: "另一个会话持有重叠的写入范围,取得所需范围后会自动继续",
|
||||
workspace_lease_resumed: "这个会话已取得所需的写入范围,现已继续",
|
||||
workspace_lease_abandoned: "尚未取得所需的写入范围,这次等待已结束",
|
||||
permission_saved: "已记住这项授权,以后同样的操作不再询问",
|
||||
permission_saved: "已记住这项授权,以后同样的操作自动允许,不再询问",
|
||||
permission_covered: "已有的授权规则覆盖了这项操作,无需另存",
|
||||
permission_save_failed: "授权没能保存,只在本次会话内有效",
|
||||
project_programs_awaiting_approval: "这个项目自带的钩子等程序要你批准后才会运行",
|
||||
project_program_changed: "这个项目的程序在批准后被改动过,本次没有运行,需要重新批准",
|
||||
hook_unevaluable: "有一个钩子无法被评估(匹配串无效、无法启动或载荷无法序列化),为安全起见已拦截这次操作;到钩子设置里修复或移除该钩子",
|
||||
suspected_injection: "一条外部内容看起来在向智能体下指令,已提醒它只当资料看待",
|
||||
await_user: "等待你的输入",
|
||||
default_model_unavailable: "配置里保存的默认模型已不在已配置的供应商中,本次改用第一个可用的模型;在设置里重新选择默认模型即可替换,配置文件未被改动",
|
||||
approval_mode_unrecognized: "配置里的默认审批档位本版本不认识,已按每次询问处理;在界面里选一个档位即可替换",
|
||||
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
|
||||
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
|
||||
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
|
||||
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
|
||||
context_budget: "上下文已用到压缩阈值的 {percent}%,已告知模型约剩 {remaining} 个词元的空间。",
|
||||
display_currency: "费用显示币种已设为 {mode}",
|
||||
compacted: "已压缩",
|
||||
compact_declined: "无需压缩:{why}",
|
||||
compact_failed: "压缩失败:{why}",
|
||||
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
|
||||
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
|
||||
};
|
||||
@@ -8,6 +8,8 @@ import "./styles/app.css";
|
||||
import "./styles/studio.css";
|
||||
import "./styles/feedback.css";
|
||||
import "./styles/community.css";
|
||||
import "./styles/chart.css";
|
||||
import "./styles/versions.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
|
||||
@@ -15,6 +15,9 @@ export const FEEDBACK_STATUSES: readonly FeedbackStatus[] = [
|
||||
"received", "needs_info", "answered", "recorded", "in_progress", "fixed", "wontfix", "duplicate", "closed",
|
||||
];
|
||||
|
||||
export const isUnderReview = (item: Pick<FeedbackItem, "status" | "underReview" | "statusUnavailable">): boolean =>
|
||||
item.status === "received" && item.underReview === true && !item.statusUnavailable;
|
||||
|
||||
// Only these take a reply from the reporter; the service has the last word.
|
||||
export const FEEDBACK_REPLYABLE: readonly FeedbackStatus[] = ["needs_info", "answered", "recorded", "in_progress"];
|
||||
|
||||
@@ -76,6 +79,8 @@ export interface FeedbackReceipt {
|
||||
status: FeedbackStatus;
|
||||
createdAt: string;
|
||||
redacted: boolean;
|
||||
// A maintainer is reading the report before it is listed; status stays received.
|
||||
underReview: boolean;
|
||||
}
|
||||
|
||||
export type FeedbackAuthor = "maintainer" | "user";
|
||||
@@ -102,6 +107,8 @@ export interface FeedbackItem {
|
||||
statusUnavailable?: boolean;
|
||||
// The maintainers are waiting for the reporter's answer.
|
||||
needsInput: boolean;
|
||||
// A received report a maintainer is reading; a service that omits it reads as false.
|
||||
underReview: boolean;
|
||||
// Oldest first.
|
||||
replies: FeedbackReply[];
|
||||
// Maintainer replies newer than the last one the kernel was told was seen.
|
||||
@@ -110,16 +117,59 @@ export interface FeedbackItem {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// What the service admits from this install right now. It follows live trust,
|
||||
// so it can sit below what the level alone would allow.
|
||||
export interface FeedbackEffectiveLimits {
|
||||
reportsPerHour: number;
|
||||
reportsPerDay: number;
|
||||
repliesPerHour: number;
|
||||
}
|
||||
|
||||
// Why the install is admitted at the limits it is. Only active and
|
||||
// legacy_active raise them; a value the page does not know raises nothing.
|
||||
export type FeedbackTrustState = "active" | "legacy_active" | "lapsed" | "revoked" | "none";
|
||||
|
||||
// The install's earned standing, derived by the service from shipped outcomes
|
||||
// and never sent by the page. The thresholds ride with it, so the page holds no
|
||||
// table. The next* fields and remaining are all null at the top level.
|
||||
export interface FeedbackProfile {
|
||||
level: number;
|
||||
adoptedCount: number;
|
||||
currentThreshold: number;
|
||||
nextLevel: number | null;
|
||||
nextThreshold: number | null;
|
||||
remaining: number | null;
|
||||
trustState: FeedbackTrustState;
|
||||
trustExpiresAt: string | null;
|
||||
observedAt: string;
|
||||
effectiveLimits: FeedbackEffectiveLimits;
|
||||
}
|
||||
|
||||
// offline: the service was unreachable, so items are the receipts remembered
|
||||
// on this machine and their statuses may be stale.
|
||||
// on this machine and their statuses may be stale; profile is then the last one
|
||||
// the service confirmed.
|
||||
// unread counts the reports wanting attention; hasNew is unread > 0.
|
||||
// profile is null when the service stated none the kernel could trust.
|
||||
export interface FeedbackMine {
|
||||
items: FeedbackItem[];
|
||||
offline: boolean;
|
||||
unread: number;
|
||||
hasNew: boolean;
|
||||
profile?: FeedbackProfile | null;
|
||||
}
|
||||
|
||||
// The window a refusal came from, as the service names it. A refusal carries
|
||||
// these in its params; an identifier not listed here is still a window.
|
||||
export const FEEDBACK_LIMIT = {
|
||||
ipHourly: "ip_hourly",
|
||||
installHourly: "install_hourly",
|
||||
installDaily: "install_daily",
|
||||
replyHourly: "reply_hourly",
|
||||
replyItem: "reply_item",
|
||||
globalDaily: "global_daily",
|
||||
globalBurst: "global_burst",
|
||||
} as const;
|
||||
|
||||
export const FEEDBACK_REPO_ISSUES = "https://github.com/esengine/DeepSeek-Reasonix/issues/";
|
||||
|
||||
export const FEEDBACK_NEXT_VERSION = "next";
|
||||
|
||||
@@ -51,8 +51,8 @@ export interface HostPort {
|
||||
* a picture of what it showed at that moment, or "" when none was taken. */
|
||||
freezeBrowserView(): Promise<string>;
|
||||
controlBrowserView(target: string, action: BrowserControl): void;
|
||||
/** Load what the person typed. false when the shell refused the address. */
|
||||
navigateBrowserView(target: string, address: string): Promise<boolean>;
|
||||
/** Load what the person typed. "" when it loads, else why the shell refused it. */
|
||||
navigateBrowserView(target: string, address: string): Promise<BrowserRefusal>;
|
||||
/** Why a page did not load, or null once another load starts. */
|
||||
onBrowserLoadState(listener: (state: BrowserLoadState) => void): () => void;
|
||||
/** A page or a proxy asking for a login; answered with answerBrowserLogin. */
|
||||
@@ -63,6 +63,10 @@ export interface HostPort {
|
||||
trustBrowserCertificate(target: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Why the shell will not load a typed address: a scheme it never opens, or a
|
||||
* file on another machine. */
|
||||
export type BrowserRefusal = "" | "scheme" | "network_file";
|
||||
|
||||
/** A kernel refusal as the shell hands it back: the code, and English fallback. */
|
||||
export interface Refusal {
|
||||
code?: string;
|
||||
@@ -135,7 +139,7 @@ interface ElectronBridge {
|
||||
hideBrowserView?(): Promise<void>;
|
||||
freezeBrowserView?(): Promise<string>;
|
||||
controlBrowserView?(target: string, action: string): Promise<void>;
|
||||
navigateBrowserView?(target: string, address: string): Promise<boolean>;
|
||||
navigateBrowserView?(target: string, address: string): Promise<BrowserRefusal>;
|
||||
onBrowserLoadState?(listener: (state: BrowserLoadState) => void): () => void;
|
||||
onBrowserLogin?(listener: (ask: BrowserLogin) => void): () => void;
|
||||
answerBrowserLogin?(id: string, username: string, password: string): Promise<void>;
|
||||
@@ -217,7 +221,7 @@ class ElectronHost implements HostPort {
|
||||
void this.api.controlBrowserView?.(target, action);
|
||||
}
|
||||
navigateBrowserView(target: string, address: string) {
|
||||
return this.api.navigateBrowserView?.(target, address) ?? Promise.resolve(false);
|
||||
return this.api.navigateBrowserView?.(target, address) ?? Promise.resolve<BrowserRefusal>("scheme");
|
||||
}
|
||||
onBrowserLoadState(listener: (state: BrowserLoadState) => void) {
|
||||
return this.api.onBrowserLoadState?.(listener) ?? (() => {});
|
||||
@@ -277,7 +281,7 @@ class BrowserHost implements HostPort {
|
||||
}
|
||||
controlBrowserView() {}
|
||||
navigateBrowserView() {
|
||||
return Promise.resolve(false);
|
||||
return Promise.resolve<BrowserRefusal>("scheme");
|
||||
}
|
||||
onBrowserLoadState() {
|
||||
return () => {};
|
||||
|
||||
@@ -24,3 +24,15 @@ export class HttpError extends Error {
|
||||
this.detailed = detailed;
|
||||
}
|
||||
}
|
||||
|
||||
export type DeliveryFault = "kernel_busy" | "unreachable" | "ui_stalled";
|
||||
|
||||
// A call the kernel never answered within its bounded wait. The request may or
|
||||
// may not have landed, so the identity is the fault: the kernel was slow, could
|
||||
// not be reached, or this window's own event loop was too starved to say which.
|
||||
export class DeliveryError extends Error {
|
||||
constructor(readonly fault: DeliveryFault) {
|
||||
super("request unconfirmed: " + fault);
|
||||
this.name = "DeliveryError";
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export interface TreeSession {
|
||||
// pane rather than opening a second writer for one file.
|
||||
runtimeId?: string;
|
||||
archived?: boolean;
|
||||
// A turn finished since the person last looked. Absent means seen.
|
||||
unread?: boolean;
|
||||
// Conflict-recovery copies of this same conversation. A save that keeps
|
||||
// conflicting writes one per turn, all under one title.
|
||||
copies?: TreeSession[];
|
||||
|
||||
@@ -51,18 +51,27 @@ export interface MarketVersion {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// Present when the registry could not answer and this is its last good copy.
|
||||
// cause says why: the registry was unreachable, or it answered unusably.
|
||||
export interface MarketCache {
|
||||
cachedAt: string;
|
||||
cause: "unreachable" | "bad_response";
|
||||
}
|
||||
|
||||
export interface MarketDetail {
|
||||
package: MarketPackage;
|
||||
approved?: MarketVersion;
|
||||
// False means an install needs the person's trust (market.unpinned without it).
|
||||
pinned: boolean;
|
||||
installed?: MarketInstalled;
|
||||
cache?: MarketCache;
|
||||
}
|
||||
|
||||
export interface MarketList {
|
||||
packages: MarketPackage[];
|
||||
limit: number;
|
||||
offset: number;
|
||||
cache?: MarketCache;
|
||||
}
|
||||
|
||||
export interface MarketQuery {
|
||||
@@ -72,6 +81,8 @@ export interface MarketQuery {
|
||||
offset?: number;
|
||||
// Filtered by the registry, so paging stays whole.
|
||||
pinned?: boolean;
|
||||
// A repeated read: skips the kernel's fresh window and asks the registry.
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
// version is the approved version the person was shown; the kernel refuses an
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, ChipCall, NotifyPrefs, TrayPrefs, UsageQuery } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockFeedback } from "./mock_feedback";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
import { MockExecutionHold, mockExecutionGraph } from "./mock_graph";
|
||||
import { mockStorage, mockStoragePlan } from "./mock_storage";
|
||||
import { MEMORIES } from "./mock_memory";
|
||||
import { mockModels } from "./mock_models";
|
||||
import { mockUsage } from "./mock_usage";
|
||||
|
||||
|
||||
@@ -81,45 +82,25 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
this.assigned = { ...this.assigned, [role]: ref };
|
||||
}
|
||||
|
||||
// Two protocols onto one host, plus a second vendor carrying the only model
|
||||
// that reads images: the two shapes the picker has to render correctly.
|
||||
overrides: Record<string, RoleOverride[]> = {};
|
||||
|
||||
async roleOverrides(): Promise<Record<string, RoleOverride[]>> {
|
||||
return this.overrides;
|
||||
}
|
||||
|
||||
async clearRoleOverride(role: string, key: string) {
|
||||
this.overrides = { ...this.overrides, [role]: (this.overrides[role] ?? []).filter((o) => o.key !== key) };
|
||||
}
|
||||
|
||||
async models(): Promise<ModelEntry[]> {
|
||||
const efforts = ["auto", "low", "high", "max"];
|
||||
return [
|
||||
{
|
||||
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
|
||||
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
|
||||
},
|
||||
{
|
||||
ref: "deepseek-anthropic/deepseek-v4-pro", provider: "deepseek-anthropic",
|
||||
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY",
|
||||
efforts, effort: "high", contextWindow: 131072,
|
||||
},
|
||||
{
|
||||
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
|
||||
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
|
||||
},
|
||||
{
|
||||
ref: "kimi/kimi-k2-vision", provider: "kimi", model: "kimi-k2-vision",
|
||||
kind: "openai", vendor: "api.moonshot.cn", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
|
||||
},
|
||||
{
|
||||
ref: "myrelay/gpt-4o", provider: "myrelay", model: "gpt-4o", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
|
||||
},
|
||||
{
|
||||
ref: "myrelay/claude-sonnet-4", provider: "myrelay", model: "claude-sonnet-4", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
ref: "myrelay-work/gpt-4o", provider: "myrelay-work", model: "gpt-4o", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_WORK_API_KEY", contextWindow: 131072,
|
||||
},
|
||||
];
|
||||
return mockModels(efforts).map((m) => ({ ...m, default: m.ref === this.defaultRef }));
|
||||
}
|
||||
|
||||
// The one model the catalogue marks as default, so a pick in the settings row
|
||||
// has something to read back after the list is reloaded.
|
||||
private defaultRef = "";
|
||||
|
||||
private mem: MemoryEntry[] = MEMORIES.map((m) => ({ ...m }));
|
||||
|
||||
async usage(query: number | UsageQuery): Promise<UsageReport> {
|
||||
@@ -200,6 +181,9 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
async markWelcomed(): Promise<void> { this.welcomed = true; }
|
||||
|
||||
async pinVersion(): Promise<void> {}
|
||||
async versionNotes(version: string): Promise<VersionNotes> {
|
||||
return { version, markdown: "", cached: false };
|
||||
}
|
||||
async acknowledgeLaunchHealth(): Promise<void> {} // booted from no update
|
||||
async goToVersion(): Promise<void> {
|
||||
throw new Error("演示模式不会真的安装版本");
|
||||
@@ -348,6 +332,8 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
|
||||
async deleteSession(_name: string) {}
|
||||
|
||||
async markSessionViewed() {}
|
||||
|
||||
async status() {
|
||||
return { ...this.state };
|
||||
}
|
||||
@@ -538,6 +524,7 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
// that wait is the only window in which taking it back means anything. A
|
||||
// fixture that echoed it at once made the state undesignable.
|
||||
async steer(text: string): Promise<Queued> {
|
||||
if (this.queuePaused) return this.queueFollowup(text);
|
||||
const itemId = `inbox-${this.queued.size + 1}-${Date.now()}`;
|
||||
const at = window.setTimeout(() => {
|
||||
// Three states, not two. The kernel marks the line consumed when the turn
|
||||
@@ -613,7 +600,7 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
async queueFollowup(text: string): Promise<Queued> {
|
||||
const itemId = `inbox-followup-${Date.now()}`;
|
||||
this.addQueued(itemId, "followup", text);
|
||||
return { itemId, disposition: "queued_followup" };
|
||||
return { itemId, disposition: "queued_followup", paused: this.queuePaused };
|
||||
}
|
||||
|
||||
async browserTabs(): Promise<BrowserTab[]> {
|
||||
@@ -693,7 +680,8 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
this.dropQueued(itemId);
|
||||
}
|
||||
|
||||
async submit(text: string) {
|
||||
async submit(text: string, chips?: ChipCall): Promise<Queued | void> {
|
||||
if (chips && this.queuePaused) return this.queueFollowup(text);
|
||||
if (this.state.running) {
|
||||
this.emit({ kind: "steer", text });
|
||||
return;
|
||||
@@ -775,10 +763,13 @@ export class MockPort extends MockFeedback implements AgentPort {
|
||||
return mockStoragePlan(root, dir);
|
||||
}
|
||||
|
||||
async setModel(ref: string, _asDefault?: boolean) {
|
||||
async setModel(ref: string) {
|
||||
this.state.modelRef = ref;
|
||||
this.state.label = ref.split("/").pop() ?? ref;
|
||||
}
|
||||
async setDefaultModel(ref: string) {
|
||||
this.defaultRef = ref;
|
||||
}
|
||||
async setEffort(effort: string) {
|
||||
this.state.effort = effort;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
@@ -13,7 +13,7 @@ const DAY = 86_400_000;
|
||||
const ago = (days: number) => new Date(Date.now() - days * DAY).toISOString();
|
||||
|
||||
const item = (over: Partial<FeedbackItem> & Pick<FeedbackItem, "receipt" | "category" | "titleSnippet" | "status" | "createdAt" | "updatedAt">): FeedbackItem => ({
|
||||
needsInput: false, replies: [], unreadReplies: 0, ...over,
|
||||
needsInput: false, underReview: false, replies: [], unreadReplies: 0, ...over,
|
||||
});
|
||||
|
||||
const msg = (id: number, author: FeedbackReply["author"], body: string, days: number): FeedbackReply => ({ id, author, body, createdAt: ago(days) });
|
||||
@@ -28,6 +28,7 @@ const SEEDED: FeedbackItem[] = [
|
||||
item({ receipt: "FB-3F6G-2KV9", category: "idea", titleSnippet: "Fixed in the next build: quieter update banner", status: "fixed", issueNumber: 11390, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11390", resolvedVersion: "next", createdAt: ago(8), updatedAt: ago(0.5) }),
|
||||
item({ receipt: "FB-8Q2J-6PW4", category: "other", titleSnippet: "Please support a monorepo layout", status: "wontfix", issueNumber: 11288, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11288", createdAt: ago(12), updatedAt: ago(5) }),
|
||||
item({ receipt: "FB-4T7V-3ZH1", category: "bug", titleSnippet: "Crash when opening settings on a small window", status: "duplicate", issueNumber: 11301, issueUrl: "https://github.com/esengine/DeepSeek-Reasonix/issues/11301", duplicateOf: 11299, createdAt: ago(15), updatedAt: ago(9) }),
|
||||
item({ receipt: "FB-5R2T-6VB3", category: "bug", titleSnippet: "Settings sheet flickers when the window is dragged", status: "received", underReview: true, createdAt: ago(0.3), updatedAt: ago(0.3) }),
|
||||
item({ receipt: "FB-6M9R-5CE8", category: "question", titleSnippet: "How do I move my sessions to another disk?", status: "received", statusUnavailable: true, createdAt: ago(40), updatedAt: ago(40) }),
|
||||
item({ receipt: "FB-1C5W-7NB2", category: "bug", titleSnippet: "Terminal output is cut off after a resize", status: "needs_info", needsInput: true, createdAt: ago(2), updatedAt: ago(0.2),
|
||||
replies: [msg(31, "maintainer", "Thanks for the report. Which operating system and which shell are you using?\nAnd can you say what the window size was before you resized it?", 0.2)] }),
|
||||
@@ -49,14 +50,46 @@ function fault(key = FAULT): string {
|
||||
}
|
||||
}
|
||||
|
||||
// A tab's own sessionStorage also picks the standing the list answers with, for
|
||||
// the same reason: "l0".."l6", "legacy", "lapsed", "revoked", or "none" for a
|
||||
// service that states no profile.
|
||||
const STANDING = "rx-mock-feedback-standing";
|
||||
|
||||
const THRESHOLDS = [0, 1, 3, 6, 12, 24, 48];
|
||||
const LEVEL_LIMITS = [[3, 10, 3], [5, 15, 4], [6, 20, 5], [8, 25, 6], [10, 30, 8], [12, 40, 10], [12, 60, 10]] as const;
|
||||
const BASE_LIMITS = LEVEL_LIMITS[0];
|
||||
const TRUSTED_LIMITS = LEVEL_LIMITS[6];
|
||||
|
||||
function standing(kind: string): FeedbackProfile | null {
|
||||
if (kind === "none") return null;
|
||||
const level = /^l[0-6]$/.test(kind) ? Number(kind.slice(1)) : kind === "lapsed" ? 3 : kind === "revoked" ? 2 : kind === "legacy" ? 0 : 2;
|
||||
const adopted = kind === "legacy" ? 0 : THRESHOLDS[level]! + (level === 6 ? 2 : level === 0 ? 0 : 1);
|
||||
const next = THRESHOLDS[level + 1];
|
||||
const state = kind === "lapsed" ? "lapsed" : kind === "revoked" ? "revoked" : kind === "legacy" ? "legacy_active" : level === 0 ? "none" : "active";
|
||||
const [h, d, r] = state === "lapsed" || state === "revoked" || state === "none" ? BASE_LIMITS : state === "legacy_active" ? TRUSTED_LIMITS : LEVEL_LIMITS[level]!;
|
||||
return {
|
||||
level, adoptedCount: adopted, currentThreshold: THRESHOLDS[level]!,
|
||||
nextLevel: next === undefined ? null : level + 1, nextThreshold: next ?? null, remaining: next === undefined ? null : next - adopted,
|
||||
trustState: state, trustExpiresAt: state === "active" || state === "legacy_active" ? new Date(Date.now() + 20 * DAY).toISOString() : null,
|
||||
observedAt: new Date().toISOString(), effectiveLimits: { reportsPerHour: h, reportsPerDay: d, repliesPerHour: r },
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS: Record<string, number> = {
|
||||
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
|
||||
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
|
||||
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
|
||||
};
|
||||
|
||||
function refusal(code: string): HttpError {
|
||||
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params: code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {} });
|
||||
// A fault is a code, optionally followed by "@" and the window it names:
|
||||
// "feedback.rate_limited@install_daily".
|
||||
function refusal(fault: string): HttpError {
|
||||
const [code = "", limit] = fault.split("@");
|
||||
const soon = new Date(Date.now() + (limit === "install_daily" ? 5 * 3_600_000 : 25 * 60_000)).toISOString();
|
||||
let params: Record<string, string | number> = code === FEEDBACK_CODE.rateLimited ? { retryAfterSeconds: 90 } : {};
|
||||
if (limit === "reply_item") params = { limit };
|
||||
else if (limit) params = { limit, resetsAt: soon, retryAfterSeconds: Math.round((Date.parse(soon) - Date.now()) / 1000) };
|
||||
return new HttpError(STATUS[code] ?? 500, code, { code, error: code, params });
|
||||
}
|
||||
|
||||
export class MockFeedback extends MockCommit {
|
||||
@@ -74,12 +107,12 @@ export class MockFeedback extends MockCommit {
|
||||
if (code === "feedback.invalid") {
|
||||
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "too_long" } });
|
||||
}
|
||||
if (code && STATUS[code]) throw refusal(code);
|
||||
if (code && STATUS[code.split("@")[0]!]) throw refusal(code);
|
||||
this.name = req.displayName;
|
||||
const receipt = "FB-" + (1000 + this.filed.length * 7).toString(36).toUpperCase().padStart(4, "K") + "-9QX2";
|
||||
const now = new Date().toISOString();
|
||||
this.filed = [item({ receipt, category: req.category, titleSnippet: req.body.trim().slice(0, 80), status: "received", createdAt: now, updatedAt: now }), ...this.filed];
|
||||
return { receipt, status: "received", createdAt: now, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
|
||||
return { receipt, status: "received", createdAt: now, underReview: false, redacted: /sk-[A-Za-z0-9]{8,}/.test(req.body) };
|
||||
}
|
||||
|
||||
async myFeedback(): Promise<FeedbackMine> {
|
||||
@@ -90,7 +123,7 @@ export class MockFeedback extends MockCommit {
|
||||
return { ...i, needsInput: i.statusUnavailable ? false : i.needsInput, unreadReplies };
|
||||
});
|
||||
const unread = items.filter((i) => i.unreadReplies > 0 || i.needsInput).length;
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0 };
|
||||
return { items, offline: code === "mine_offline", unread, hasNew: unread > 0, profile: standing(fault(STANDING)) };
|
||||
}
|
||||
|
||||
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
|
||||
|
||||
@@ -170,9 +170,9 @@ export class MockHub implements HubPort {
|
||||
i === 0
|
||||
? [
|
||||
{ path: "/sessions/mock.jsonl", name: "mock", title: "并行会话演示", turns: 3, runtimeId: open.get("/sessions/mock.jsonl") },
|
||||
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12 },
|
||||
{ path: "/sessions/older.jsonl", name: "older", title: "上一次的会话", turns: 12, runtimeId: open.get("/sessions/older.jsonl") },
|
||||
]
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5 }],
|
||||
: [{ path: "/sessions/site.jsonl", name: "site", title: "站点改版", turns: 5, runtimeId: open.get("/sessions/site.jsonl") }],
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { MarketCache, MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockLook } from "./mock_look";
|
||||
|
||||
@@ -62,20 +62,22 @@ export class MockMarket extends MockLook {
|
||||
private marketVotes = new Map<string, -1 | 0 | 1>([["acme/review-kit", 1]]);
|
||||
// Voting reads its own sign-in answer so a test can draw the signed-out entry.
|
||||
marketSignedIn = true;
|
||||
// Set to draw the listing and the entry as the last good copy.
|
||||
marketCache: MarketCache | undefined;
|
||||
|
||||
async marketList(q: MarketQuery): Promise<MarketList> {
|
||||
const needle = (q.q ?? "").trim().toLowerCase();
|
||||
const rows = PACKAGES.filter((p) => (!q.kind || p.kind === q.kind) && (!q.pinned || p.pinned) &&
|
||||
(!needle || `${p.name} ${p.summary} ${p.tags.join(" ")}`.toLowerCase().includes(needle)));
|
||||
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0 };
|
||||
return { packages: rows.map((p) => this.view(p)), limit: 24, offset: 0, ...(this.marketCache ? { cache: this.marketCache } : {}) };
|
||||
}
|
||||
|
||||
async marketDetail(slug: string): Promise<MarketDetail> {
|
||||
async marketDetail(slug: string, _opts?: { refresh?: boolean }): Promise<MarketDetail> {
|
||||
const p = PACKAGES.find((x) => x.slug === slug);
|
||||
if (!p) throw new Error("market.not_found");
|
||||
const pkg = this.view(p);
|
||||
return {
|
||||
package: pkg, pinned: p.pinned, installed: pkg.installed,
|
||||
package: pkg, pinned: p.pinned, installed: pkg.installed, ...(this.marketCache ? { cache: this.marketCache } : {}),
|
||||
approved: { version: p.latestVersion, source: p.source, contentHash: p.pinned ? DIGEST : "", riskLevel: "", createdAt: p.updatedAt },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { ModelEntry } from "./port";
|
||||
|
||||
// The catalogue the model rows read: two protocols onto one host, plus a second
|
||||
// vendor carrying the only model that reads images — the two shapes the picker
|
||||
// has to render correctly. The effort list stays the caller's, so a test can
|
||||
// hand in its own.
|
||||
export function mockModels(efforts: string[]): ModelEntry[] {
|
||||
return [
|
||||
{
|
||||
ref: "deepseek/deepseek-v4-pro", provider: "deepseek", model: "deepseek-v4-pro",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", active: true, efforts, effort: "high",
|
||||
contextWindow: 131072, price: { input: 2, output: 8, currency: "CNY" },
|
||||
},
|
||||
{
|
||||
ref: "deepseek-anthropic/deepseek-v4-pro", provider: "deepseek-anthropic",
|
||||
model: "deepseek-v4-pro", kind: "anthropic", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY",
|
||||
efforts, effort: "high", contextWindow: 131072,
|
||||
},
|
||||
{
|
||||
ref: "deepseek/deepseek-flash", provider: "deepseek", model: "deepseek-flash",
|
||||
kind: "openai", vendor: "api.deepseek.com", keyEnv: "DEEPSEEK_API_KEY", efforts, effort: "high",
|
||||
contextWindow: 131072, price: { input: 0.5, output: 2, currency: "CNY" },
|
||||
},
|
||||
{
|
||||
ref: "kimi/kimi-k2-vision", provider: "kimi", model: "kimi-k2-vision",
|
||||
kind: "openai", vendor: "api.moonshot.cn", keyEnv: "KIMI_API_KEY", vision: true, contextWindow: 262144,
|
||||
},
|
||||
{
|
||||
ref: "myrelay/gpt-4o", provider: "myrelay", model: "gpt-4o", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", vision: true, contextWindow: 131072,
|
||||
},
|
||||
{
|
||||
ref: "myrelay/claude-sonnet-4", provider: "myrelay", model: "claude-sonnet-4", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_API_KEY", contextWindow: 200000,
|
||||
},
|
||||
{
|
||||
ref: "myrelay-work/gpt-4o", provider: "myrelay-work", model: "gpt-4o", kind: "openai",
|
||||
vendor: "relay.example.com", keyEnv: "MYRELAY_WORK_API_KEY", contextWindow: 131072,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -98,7 +98,7 @@ export class MockProvider extends MockBoundary {
|
||||
// "改用…" repair exists for. The relay answers at gateway scale, which is the
|
||||
// case the model list's search and its row cap exist for.
|
||||
async checkProvider(name: string): Promise<ProviderCheck> {
|
||||
if (name === "mimo") return { ok: false, error: "401 unauthorized: key 过期了" };
|
||||
if (name === "mimo") return { ok: false, code: "provider.probe.unauthorized", httpStatus: 401, detail: "Invalid Authentication" };
|
||||
const models = name.startsWith("myrelay")
|
||||
? relayCatalog()
|
||||
: ["deepseek-v4-pro", "deepseek-flash", "deepseek-flash-vision-exp"];
|
||||
@@ -155,6 +155,7 @@ export class MockProvider extends MockBoundary {
|
||||
? {
|
||||
...p, models: edit.models, default: edit.default, visionModels: edit.vision,
|
||||
contextWindow: edit.contextWindow, maxOutputTokens: edit.maxOutputTokens,
|
||||
idleTimeoutSeconds: edit.idleTimeoutSeconds || undefined,
|
||||
headers: edit.headers, extraBody: edit.extraBody,
|
||||
reasoningProtocol: edit.reasoningProtocol, supportedEfforts: edit.supportedEfforts,
|
||||
defaultEffort: edit.defaultEffort,
|
||||
|
||||
@@ -91,6 +91,14 @@ export interface RoleAssignments {
|
||||
decision: string;
|
||||
}
|
||||
|
||||
// A per-profile entry that outranks a role's global model. `scope` names the
|
||||
// file holding it: only a "user" entry can be cleared from the settings page.
|
||||
export interface RoleOverride {
|
||||
key: string;
|
||||
model: string;
|
||||
scope: "user" | "project";
|
||||
}
|
||||
|
||||
// The two fold bounds and which of them is in force. They are configured
|
||||
// separately and only the lower one ever fires, so a panel showing the
|
||||
// settings alone cannot say which: a 1M window against the default soft limit
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { DeliveryError, HttpError } from "./http_error";
|
||||
import type { DeliveryFault } from "./http_error";
|
||||
import type { Attachment, DroppedRef } from "./attachment";
|
||||
export { HttpError };
|
||||
export { DeliveryError, HttpError };
|
||||
export type { DeliveryFault };
|
||||
export type { Attachment, DroppedRef };
|
||||
|
||||
import type { AccountState, AccountUser, DeviceGrant } from "./account";
|
||||
@@ -15,12 +17,12 @@ import type { UsageQuery, UsageReport } from "./usage";
|
||||
export { DEFAULT_USAGE_DAYS } from "./usage";
|
||||
export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments } from "./model";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub } from "./version";
|
||||
import type { UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo } from "./workspace";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
@@ -31,9 +33,9 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
|
||||
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindScope, RoleAssignments, ScopeLayer, SessionEntry, SessionStatus,
|
||||
RewindResult, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
VersionHub, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceInfo };
|
||||
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
@@ -85,6 +87,9 @@ export interface ChipCall {
|
||||
export interface Queued {
|
||||
itemId: string;
|
||||
disposition?: string;
|
||||
// The queue was held when the line arrived: it waits there as a follow-up and
|
||||
// nothing dispatches it until the queue is resumed.
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** GET /inbox. What is actually waiting, kernel-side. The optimistic rows this
|
||||
@@ -189,7 +194,7 @@ export interface AgentPort {
|
||||
// The community market: a source like a pasted address, not a trust root.
|
||||
// Install is the same plan-then-apply pair, pinned to the reviewed digest.
|
||||
marketList(q: MarketQuery): Promise<MarketList>;
|
||||
marketDetail(slug: string): Promise<MarketDetail>;
|
||||
marketDetail(slug: string, opts?: { refresh?: boolean }): Promise<MarketDetail>;
|
||||
planMarket(req: MarketRequest): Promise<MarketPlan>;
|
||||
installMarket(req: MarketRequest): Promise<MarketPlan>;
|
||||
// Publishing spends the account session; both refuse when signed out.
|
||||
@@ -298,6 +303,11 @@ export interface AgentPort {
|
||||
// Persisted, then the runtime is rebuilt: boot reads every role model while
|
||||
// assembling, so an assignment cannot reach a runtime that is already up.
|
||||
setRole(role: string, ref: string): Promise<void>;
|
||||
// Entries that win over a role's global model, per role. The global value
|
||||
// roles() reports is not what runs while one of these exists.
|
||||
roleOverrides(): Promise<Record<string, RoleOverride[]>>;
|
||||
// Removes one user-config entry and rebuilds, so the global model takes over.
|
||||
clearRoleOverride(role: string, key: string): Promise<void>;
|
||||
storage(query?: StorageQuery): Promise<StorageState>;
|
||||
planStorageMove(root: string, dir: string): Promise<StoragePlan>;
|
||||
moveStorage(root: string, dir: string): Promise<StoragePlan>;
|
||||
@@ -330,6 +340,8 @@ export interface AgentPort {
|
||||
removeProvider(name: string): Promise<void>;
|
||||
versions(): Promise<VersionHub>;
|
||||
pinVersion(version: string): Promise<void>;
|
||||
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
|
||||
versionNotes(version: string, retry?: boolean): Promise<VersionNotes>;
|
||||
// Installs a published version, forward or back — the same call either way,
|
||||
// because a rollback that took a second code path would be the less-tested
|
||||
// one. Resolves only on failure: a success ends with the process handing over
|
||||
@@ -395,6 +407,9 @@ export interface AgentPort {
|
||||
sessions(): Promise<SessionEntry[]>;
|
||||
resume(path: string): Promise<void>;
|
||||
newSession(): Promise<void>;
|
||||
// Clears the unread mark on the conversation this pane has open. Unmarked
|
||||
// sessions are a no-op on the kernel, and an older kernel answers 404.
|
||||
markSessionViewed(): Promise<void>;
|
||||
deleteSession(name: string): Promise<void>;
|
||||
status(): Promise<SessionStatus>;
|
||||
/** The provider's wallet, or null when this provider has no wallet endpoint —
|
||||
@@ -466,7 +481,7 @@ export interface AgentPort {
|
||||
|
||||
// chips carries skill chips beside the line; without it the line is parsed
|
||||
// as typed, so a kernel that predates chips still runs a leading one.
|
||||
submit(text: string, chips?: ChipCall): Promise<void>;
|
||||
submit(text: string, chips?: ChipCall): Promise<Queued | void>;
|
||||
// /submit 409s once a turn holds the session. Mid-turn input is durable and
|
||||
// goes through the inbox, which delivers it at the next tool boundary. The
|
||||
// receipt is what makes it cancellable while it waits there.
|
||||
@@ -552,8 +567,12 @@ export interface AgentPort {
|
||||
// The person's answer to "trust this folder?" for the session's workspace.
|
||||
decideWorkspaceTrust(trust: "trusted" | "declined"): Promise<void>;
|
||||
setPreset(preset: Preset): Promise<void>;
|
||||
// Switches this session only; asDefault also records it as the model new sessions start on.
|
||||
setModel(ref: string, asDefault?: boolean): Promise<void>;
|
||||
// Switches this session only.
|
||||
setModel(ref: string): Promise<void>;
|
||||
// Records the model new sessions start on in the config of the machine this
|
||||
// port answers for. A brokered pane's default lives on the machine its models
|
||||
// come from, so its refusal is typed: settings.default_model_brokered.
|
||||
setDefaultModel(ref: string): Promise<void>;
|
||||
setEffort(effort: string): Promise<void>;
|
||||
// "" turns the session's model mode off.
|
||||
setModelMode(mode: string): Promise<void>;
|
||||
|
||||
@@ -62,6 +62,11 @@ export interface ProviderEntry {
|
||||
// on top of the protocol.
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
// Seconds the endpoint may stay silent before the call is read as dropped;
|
||||
// absent is the built-in default.
|
||||
idleTimeoutSeconds?: number;
|
||||
// What a source without one waits; the kernel owns the number.
|
||||
idleTimeoutDefault?: number;
|
||||
headers?: Record<string, string>;
|
||||
extraBody?: Record<string, unknown>;
|
||||
}
|
||||
@@ -110,8 +115,9 @@ export interface ProviderProbe {
|
||||
noProxy: boolean;
|
||||
}
|
||||
|
||||
// What re-probing a saved provider found. `error` carries the endpoint's own
|
||||
// words, because "401" and "no chat models" send the user to different fixes.
|
||||
// What re-probing a saved provider found. A failure carries `code`, the dotted
|
||||
// identity the add flow's refusals use, because "401" and "no chat models" send
|
||||
// the user to different fixes; the endpoint's own words ride along as `detail`.
|
||||
export interface ProviderCheck {
|
||||
ok: boolean;
|
||||
kind?: string;
|
||||
@@ -126,7 +132,12 @@ export interface ProviderCheck {
|
||||
vision?: string[];
|
||||
ambiguous?: boolean;
|
||||
noProxy?: boolean;
|
||||
error?: string;
|
||||
code?: string;
|
||||
// Only the numbers the code's sentence needs: `status`, `count`.
|
||||
params?: Record<string, number>;
|
||||
httpStatus?: number;
|
||||
// The endpoint's error text, for display; never an input to `code`.
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export type ProviderModelCheckStatus = "available" | "unavailable" | "unknown";
|
||||
@@ -176,6 +187,8 @@ export interface ProviderEdit {
|
||||
// compaction off for this source.
|
||||
contextWindow?: number;
|
||||
maxOutputTokens?: number;
|
||||
// 0 is the built-in default; omitted leaves the stored value alone.
|
||||
idleTimeoutSeconds?: number;
|
||||
headers?: Record<string, string>;
|
||||
extraBody?: Record<string, unknown>;
|
||||
// Which request shape controls thinking here. "" is auto — no declaration,
|
||||
|
||||
@@ -34,6 +34,10 @@ export interface RemoteHost {
|
||||
useSSHConfig?: boolean;
|
||||
passphraseEnv?: string;
|
||||
passwordEnv?: string;
|
||||
// Off: the machine stays in the book but is not dialed — no pane opens on it
|
||||
// and the rail leaves it out. Absent means on, so an old config reads as it
|
||||
// always did.
|
||||
disabled?: boolean;
|
||||
// Set from the CLI, with no control here — counted so an edit does not look
|
||||
// like it dropped them.
|
||||
forwards?: number;
|
||||
@@ -83,6 +87,7 @@ export interface RemoteHostEdit {
|
||||
useSSHConfig?: boolean;
|
||||
passphraseEnv?: string;
|
||||
passwordEnv?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
// One folder on another machine. The name is carried beside the path because
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import type { AccountState, AgentPort, Appearance, ChipCall, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, VersionNotes, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, RoleOverride, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageQuery, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type ChangeDiff, type CommitProposal, type CommitRequest, type CommitResult, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
@@ -191,6 +191,14 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return this.post("/roles", { role, ref });
|
||||
}
|
||||
|
||||
roleOverrides() {
|
||||
return this.get<Record<string, RoleOverride[]>>("/roles/overrides");
|
||||
}
|
||||
|
||||
clearRoleOverride(role: string, key: string) {
|
||||
return this.post("/roles/overrides/clear", { role, key });
|
||||
}
|
||||
|
||||
storage(query?: StorageQuery) {
|
||||
const q = query?.layout ? "?layout=1" : query?.root ? "?root=" + encodeURIComponent(query.root) : "";
|
||||
return this.get<StorageState>("/storage" + q);
|
||||
@@ -233,6 +241,13 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
);
|
||||
}
|
||||
|
||||
async versionNotes(version: string, retry = false): Promise<VersionNotes> {
|
||||
const path = `/studio/versions/${encodeURIComponent(version)}/notes${retry ? "?retry=1" : ""}`;
|
||||
const res = await fetch(path, { credentials: "same-origin" });
|
||||
if (!res.ok) await SsePort.fail(path, res);
|
||||
return (await res.json()) as VersionNotes;
|
||||
}
|
||||
|
||||
async pinVersion(version: string): Promise<void> {
|
||||
const res = await fetch("/studio/pin", {
|
||||
method: "POST",
|
||||
@@ -407,6 +422,8 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return this.post("/resume", { path });
|
||||
}
|
||||
|
||||
markSessionViewed() { return this.post("/sessions/viewed"); }
|
||||
|
||||
newSession() {
|
||||
return this.post("/new");
|
||||
}
|
||||
@@ -415,26 +432,6 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return this.post("/delete-session", { name });
|
||||
}
|
||||
|
||||
// JSON, not raw bytes: csrfGuard admits nothing else, and that guard is what
|
||||
// stops a cross-site form posting here at all.
|
||||
async attach(blob: Blob, name?: string) {
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
let bin = "";
|
||||
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
||||
const res = await fetch(this.base + "/attachments", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ mime: blob.type, name: name ?? "", data: btoa(bin) }),
|
||||
});
|
||||
if (!res.ok) throw new HttpError(res.status, `/attachments: ${res.status} ${await res.text()}`);
|
||||
return (await res.json()) as Attachment;
|
||||
}
|
||||
|
||||
dropRefs(paths: string[]) {
|
||||
return this.post0<DroppedRef[]>("/drop", { paths });
|
||||
}
|
||||
|
||||
changes() {
|
||||
return this.get<WorkspaceChanges>("/changes");
|
||||
}
|
||||
@@ -665,7 +662,7 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
}
|
||||
|
||||
submit(text: string, chips?: ChipCall) {
|
||||
return this.post("/submit", { input: text, ...chips });
|
||||
return this.postMaybe<Queued>("/submit", { input: text, ...chips });
|
||||
}
|
||||
steer(text: string) {
|
||||
return this.post0<Queued>("/inbox/items", { input: text, intent: "steer" });
|
||||
@@ -718,16 +715,16 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
return this.post(paused ? "/inbox/pause" : "/inbox/resume");
|
||||
}
|
||||
cancel() {
|
||||
return this.post("/cancel");
|
||||
return this.postAcked("/cancel");
|
||||
}
|
||||
// Approve(id, allow, session, persist) — "always" is a session grant, not a
|
||||
// persisted config change.
|
||||
planDecision(id: string, action: PlanAction) {
|
||||
return this.post("/plan-decision", { id, action: PLAN_ACTIONS[action] });
|
||||
return this.postAcked("/plan-decision", { id, action: PLAN_ACTIONS[action] });
|
||||
}
|
||||
|
||||
approve(id: string, verdict: ApprovalVerdict) {
|
||||
return this.post("/approve", {
|
||||
return this.postAcked("/approve", {
|
||||
id,
|
||||
allow: verdict !== "deny",
|
||||
// A rule written down also covers the rest of this session, so the answer
|
||||
@@ -737,7 +734,7 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
});
|
||||
}
|
||||
answer(id: string, answers: { questionId: string; selected: string[] }[]) {
|
||||
return this.post("/answer", {
|
||||
return this.postAcked("/answer", {
|
||||
id,
|
||||
answers: answers.map((a) => ({ QuestionID: a.questionId, Selected: a.selected })),
|
||||
});
|
||||
@@ -776,8 +773,11 @@ export class SsePort extends SseFeedback implements AgentPort {
|
||||
setPreset(preset: Preset) {
|
||||
return this.post("/preset", { preset });
|
||||
}
|
||||
setModel(ref: string, asDefault = false) {
|
||||
return this.post("/model", asDefault ? { ref, default: true } : { ref });
|
||||
setModel(ref: string) {
|
||||
return this.post("/model", { ref });
|
||||
}
|
||||
setDefaultModel(ref: string) {
|
||||
return this.post("/default-model", { ref });
|
||||
}
|
||||
setEffort(effort: string) {
|
||||
return this.post("/effort", { effort });
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "./port";
|
||||
import { SsePort } from "./sse";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
const refusal = (status: number, body: unknown) =>
|
||||
vi.stubGlobal("fetch", async () => ({ ok: false, status, text: async () => JSON.stringify(body) }));
|
||||
|
||||
describe("attach", () => {
|
||||
it("keeps the kernel's refusal code and params on the error it throws", async () => {
|
||||
refusal(415, { code: "attachment.unsupported_image", error: "pasted data is not a supported image", params: { format: ".ico" } });
|
||||
const err = await new SsePort("", "r1").attach(new Blob(["x"], { type: "image/x-icon" }), "a.ico").catch((e) => e);
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(err.reason).toMatchObject({ code: "attachment.unsupported_image", params: { format: ".ico" } });
|
||||
});
|
||||
|
||||
it("falls back to the plain text of an older kernel", async () => {
|
||||
vi.stubGlobal("fetch", async () => ({ ok: false, status: 400, text: async () => "pasted data is not a supported image" }));
|
||||
const err = await new SsePort("", "r1").attach(new Blob(["x"]), "a.png").catch((e) => e);
|
||||
expect(err.message).toBe("pasted data is not a supported image");
|
||||
});
|
||||
|
||||
it("names an unreadable file instead of passing the browser's English through", async () => {
|
||||
vi.stubGlobal("fetch", async () => {
|
||||
throw new Error("must not be reached");
|
||||
});
|
||||
const blob = { type: "", arrayBuffer: async () => Promise.reject(new DOMException("denied", "NotReadableError")) } as unknown as Blob;
|
||||
const err = await new SsePort("", "r1").attach(blob, "locked.docx").catch((e) => e);
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(err.reason?.code).toBe("attachment.unreadable");
|
||||
});
|
||||
|
||||
it("still returns the saved reference on success", async () => {
|
||||
vi.stubGlobal("fetch", async () => ({ ok: true, status: 200, json: async () => ({ path: "p", ref: "@p", image: false }) }));
|
||||
expect(await new SsePort("", "r1").attach(new Blob(["x"]), "a.txt")).toEqual({ path: "p", ref: "@p", image: false });
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,9 @@
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import { SseIntake } from "./sse_intake";
|
||||
import type { BackupApplyResult, BackupCatalog, BackupCreated, BackupCreateRequest, BackupPlan } from "./backup";
|
||||
|
||||
// Backups kept in the signed-in account. Every call is refused while signed
|
||||
// out, so a caller shows the section only when the account says signed in.
|
||||
export class SseBackup extends SseTheme {
|
||||
export class SseBackup extends SseIntake {
|
||||
backups() {
|
||||
return this.get<BackupCatalog>("/backups");
|
||||
}
|
||||
|
||||
@@ -49,8 +49,8 @@ export class SseExtensions extends SseLook {
|
||||
const query = params.toString();
|
||||
return this.get<MarketList>("/market/packages" + (query ? "?" + query : ""));
|
||||
}
|
||||
marketDetail(slug: string) {
|
||||
return this.get<MarketDetail>(marketPath(slug));
|
||||
marketDetail(slug: string, opts?: { refresh?: boolean }) {
|
||||
return this.get<MarketDetail>(marketPath(slug) + (opts?.refresh ? "?refresh=1" : ""));
|
||||
}
|
||||
marketMyVote(slug: string) {
|
||||
return this.get<MarketVote>(marketPath(slug) + "/vote");
|
||||
|
||||
@@ -1,13 +1,135 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { HttpError } from "./port";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DeliveryError, HttpError } from "./port";
|
||||
import { SseHttp } from "./sse_http";
|
||||
import { SsePort } from "./sse";
|
||||
|
||||
class Probe extends SseHttp {
|
||||
call(path: string) {
|
||||
return this.post(path, {});
|
||||
}
|
||||
read<T>(path: string) {
|
||||
return this.get<T>(path);
|
||||
}
|
||||
decide(path: string) {
|
||||
return this.postAcked(path, {});
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function deferredFetch() {
|
||||
const pending: ((v: unknown) => void)[] = [];
|
||||
const fetchMock = vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
pending.push((v) => resolve({ ok: true, status: 200, json: async () => v, text: async () => "" }));
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return { fetchMock, pending };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((r) => setTimeout(r, 0));
|
||||
|
||||
describe("idempotent reads", () => {
|
||||
it("share one request while it is in flight, with one trailing re-read for the rest", async () => {
|
||||
const { fetchMock, pending } = deferredFetch();
|
||||
const probe = new Probe();
|
||||
const first = probe.read("/status");
|
||||
const second = probe.read("/status");
|
||||
const third = probe.read("/status");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
pending[0]({ n: 1 });
|
||||
expect(await first).toEqual({ n: 1 });
|
||||
await tick();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
pending[1]({ n: 2 });
|
||||
expect(await second).toEqual({ n: 2 });
|
||||
expect(await third).toEqual({ n: 2 });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("never hands a trigger an answer that began before it", async () => {
|
||||
const { pending } = deferredFetch();
|
||||
const probe = new Probe();
|
||||
const early = probe.read<{ n: number }>("/status");
|
||||
const late = probe.read<{ n: number }>("/status");
|
||||
pending[0]({ n: 1 });
|
||||
await early;
|
||||
await tick();
|
||||
pending[1]({ n: 2 });
|
||||
expect((await late).n).toBe(2);
|
||||
});
|
||||
|
||||
it("keeps different paths apart", async () => {
|
||||
const { fetchMock } = deferredFetch();
|
||||
const probe = new Probe();
|
||||
void probe.read("/status");
|
||||
void probe.read("/context");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not let a failed read poison the next one", async () => {
|
||||
let n = 0;
|
||||
const fetchMock = vi.fn(async () => {
|
||||
n++;
|
||||
if (n === 1) throw new TypeError("Failed to fetch");
|
||||
return { ok: true, status: 200, json: async () => ({ n }), text: async () => "" };
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const probe = new Probe();
|
||||
await expect(probe.read("/status")).rejects.toBeInstanceOf(TypeError);
|
||||
expect(await probe.read("/status")).toEqual({ n: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a decision submit", () => {
|
||||
it("fails with a typed error instead of waiting forever", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", (_: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
}),
|
||||
);
|
||||
const settled = new Probe().decide("/approve").catch((e) => e);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const err = (await settled) as DeliveryError;
|
||||
expect(err).toBeInstanceOf(DeliveryError);
|
||||
expect(err.fault).toBe("kernel_busy");
|
||||
});
|
||||
|
||||
it("keeps a refusal it already received when the wait runs out mid-body", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.stubGlobal("fetch", async (_: string, init: RequestInit) => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
text: () =>
|
||||
new Promise<string>((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
}),
|
||||
}));
|
||||
const settled = new Probe().decide("/plan-decision").catch((e) => e);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const err = await settled;
|
||||
expect(err).not.toBeInstanceOf(DeliveryError);
|
||||
expect((err as HttpError).status).toBe(409);
|
||||
});
|
||||
|
||||
it("goes through untouched when the kernel answers", async () => {
|
||||
vi.stubGlobal("fetch", async () => ({ ok: true, status: 204, text: async () => "" }));
|
||||
await expect(new Probe().decide("/approve")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still carries a refusal's code", async () => {
|
||||
answer(409, JSON.stringify({ code: "gate.stale", error: "stale" }), "application/json");
|
||||
const err = (await new Probe().decide("/approve").catch((e) => e)) as HttpError;
|
||||
expect(err.reason?.code).toBe("gate.stale");
|
||||
});
|
||||
});
|
||||
|
||||
function answer(status: number, body: string, contentType: string) {
|
||||
vi.stubGlobal("fetch", async () => ({
|
||||
ok: false,
|
||||
@@ -18,6 +140,75 @@ function answer(status: number, body: string, contentType: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
const silentKernel = () =>
|
||||
vi.stubGlobal("fetch", (_: string, init: RequestInit) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init.signal?.addEventListener("abort", () => reject(new DOMException("aborted", "AbortError")));
|
||||
}),
|
||||
);
|
||||
|
||||
describe("an unconfirmed call is told apart by who held it up", () => {
|
||||
it("a stop the kernel never acknowledged ends as a typed error, not a wait with no end", async () => {
|
||||
vi.useFakeTimers();
|
||||
silentKernel();
|
||||
const settled = new SsePort("", "r1").cancel().then(() => "acked", (e) => e);
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
const err = await settled;
|
||||
expect(err).toBeInstanceOf(DeliveryError);
|
||||
expect((err as DeliveryError).fault).toBe("kernel_busy");
|
||||
});
|
||||
|
||||
it("a stop the kernel acknowledged settles", async () => {
|
||||
const calls: string[] = [];
|
||||
vi.stubGlobal("fetch", async (url: string) => {
|
||||
calls.push(url);
|
||||
return { ok: true, status: 204, text: async () => "" };
|
||||
});
|
||||
await expect(new SsePort("", "r1").cancel()).resolves.toBeUndefined();
|
||||
expect(calls).toEqual(["/cancel"]);
|
||||
});
|
||||
|
||||
it("blames this window, not the kernel, when the deadline fires far past its time", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let now = 1_000;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
silentKernel();
|
||||
const settled = new Probe().decide("/approve").catch((e) => e);
|
||||
now += 90_000;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(((await settled) as DeliveryError).fault).toBe("ui_stalled");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("blames the kernel when the deadline fires on time", async () => {
|
||||
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
||||
let now = 1_000;
|
||||
vi.spyOn(performance, "now").mockImplementation(() => now);
|
||||
silentKernel();
|
||||
const settled = new Probe().decide("/approve").catch((e) => e);
|
||||
now += 20_050;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(((await settled) as DeliveryError).fault).toBe("kernel_busy");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports a refused connection as unreachable", async () => {
|
||||
vi.stubGlobal("fetch", async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const err = (await new Probe().decide("/approve").catch((e) => e)) as DeliveryError;
|
||||
expect(err).toBeInstanceOf(DeliveryError);
|
||||
expect(err.fault).toBe("unreachable");
|
||||
});
|
||||
|
||||
it("keeps a refusal the kernel spelled out for a stop", async () => {
|
||||
vi.stubGlobal("fetch", async () => ({ ok: false, status: 403, text: async () => '{"code":"x.y","error":"no"}' }));
|
||||
const err = (await new SsePort("", "r1").cancel().catch((e) => e)) as HttpError;
|
||||
expect(err).toBeInstanceOf(HttpError);
|
||||
expect(err.reason?.code).toBe("x.y");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a failed call", () => {
|
||||
// http.Error writes plain text, and parsing the body as JSON first threw the
|
||||
// only account of the failure away: users reported "/providers/edit: 500".
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import { HttpError } from "./port";
|
||||
import { DeliveryError, HttpError } from "./port";
|
||||
|
||||
type Refusal = { code?: string; error?: string; params?: Record<string, string | number> };
|
||||
|
||||
// How long a call the user is waiting on gets before control comes back.
|
||||
const ACK_WAIT_MS = 20_000;
|
||||
// Timers in a background tab are aligned to one second; a timer later than that
|
||||
// past its deadline was held up by this window's own event loop.
|
||||
const STALL_TOLERANCE_MS = 2_000;
|
||||
|
||||
// One idempotent read per path in flight. `next` is the single re-read owed to
|
||||
// every caller that arrived while `flight` was already on the wire.
|
||||
interface ReadSlot {
|
||||
flight: Promise<unknown>;
|
||||
next?: Promise<unknown>;
|
||||
}
|
||||
|
||||
// The transport half of SsePort: where the kernel is, and the five shapes every
|
||||
// call to it takes. Split out because the port itself is the whole AgentPort
|
||||
// surface — a hundred endpoint methods — and none of them should have to be
|
||||
// read past to find how a request is actually made.
|
||||
export class SseHttp {
|
||||
private readonly reads = new Map<string, ReadSlot>();
|
||||
|
||||
// rt names the pane this port speaks for. The shell's bus carries every
|
||||
// pane's frames, so a channel per runtime is what keeps two live
|
||||
// conversations out of each other's transcript.
|
||||
@@ -56,6 +71,20 @@ export class SseHttp {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// A POST that answers with a payload only sometimes: a plain turn start has
|
||||
// no body, a line routed through the queue carries its receipt.
|
||||
protected async postMaybe<T>(path: string, body?: unknown): Promise<T | undefined> {
|
||||
const res = await fetch(this.base + path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
const raw = await res.text();
|
||||
return raw.trim() === "" ? undefined : (JSON.parse(raw) as T);
|
||||
}
|
||||
|
||||
// A partial update of one resource. Distinct from post because the kernel
|
||||
// reads the verb: the same path answers a different question under each.
|
||||
protected async patch(path: string, body?: unknown): Promise<void> {
|
||||
@@ -96,11 +125,68 @@ export class SseHttp {
|
||||
// same fail(). It used to throw a bare Error carrying a path and a number,
|
||||
// which spent every refusal the kernel had spelled out — "this server does
|
||||
// not open account sign-in" reached the panel as "/account: 403".
|
||||
protected async get<T>(path: string): Promise<T> {
|
||||
// An idempotent read joins the one in flight for its path and, if it arrived
|
||||
// late, the single trailing re-read: a slow endpoint polled faster than it
|
||||
// answers would otherwise fill the browser's per-origin connection pool.
|
||||
protected get<T>(path: string): Promise<T> {
|
||||
const slot = this.reads.get(path);
|
||||
if (!slot) return this.startRead<T>(path);
|
||||
slot.next ??= slot.flight.then(
|
||||
() => this.startRead<T>(path),
|
||||
() => this.startRead<T>(path),
|
||||
);
|
||||
return slot.next as Promise<T>;
|
||||
}
|
||||
|
||||
private startRead<T>(path: string): Promise<T> {
|
||||
const flight = this.fetchRead<T>(path);
|
||||
const slot: ReadSlot = { flight };
|
||||
this.reads.set(path, slot);
|
||||
const release = () => {
|
||||
if (this.reads.get(path) === slot) this.reads.delete(path);
|
||||
};
|
||||
flight.then(release, release);
|
||||
return flight;
|
||||
}
|
||||
|
||||
private async fetchRead<T>(path: string): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin" });
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// An approval, plan decision, answer or stop: the caller is waiting on this
|
||||
// one call, so it gets a bounded wait and a typed failure it can recover from.
|
||||
// A starved event loop runs the deadline timer before the response callback,
|
||||
// so a deadline that fires far past its time is this window's fault, not the
|
||||
// kernel's.
|
||||
protected async postAcked(path: string, body?: unknown): Promise<void> {
|
||||
const ctl = new AbortController();
|
||||
const began = performance.now();
|
||||
let stalled = false;
|
||||
const timer = setTimeout(() => {
|
||||
stalled = performance.now() - began - ACK_WAIT_MS > STALL_TOLERANCE_MS;
|
||||
ctl.abort();
|
||||
}, ACK_WAIT_MS);
|
||||
let answered = false;
|
||||
try {
|
||||
const res = await fetch(this.base + path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
answered = true;
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
} catch (e) {
|
||||
if (!answered && ctl.signal.aborted) throw new DeliveryError(stalled ? "ui_stalled" : "kernel_busy");
|
||||
if (!answered && e instanceof TypeError) throw new DeliveryError("unreachable");
|
||||
throw e;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A capability scope rides as a query rather than a path segment: every one of
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { HttpError, type Attachment, type DroppedRef } from "./port";
|
||||
import { SseHttp } from "./sse_http";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
|
||||
// Where a file enters a turn: bytes the page holds, or paths the shell knows.
|
||||
export class SseIntake extends SseTheme {
|
||||
// JSON, not raw bytes: csrfGuard admits nothing else, and that guard is what
|
||||
// stops a cross-site form posting here at all.
|
||||
async attach(blob: Blob, name?: string) {
|
||||
let data: string;
|
||||
try {
|
||||
const buf = new Uint8Array(await blob.arrayBuffer());
|
||||
let bin = "";
|
||||
for (let i = 0; i < buf.length; i += 0x8000) bin += String.fromCharCode(...buf.subarray(i, i + 0x8000));
|
||||
data = btoa(bin);
|
||||
} catch (e) {
|
||||
const detail = String(e);
|
||||
throw new HttpError(0, detail, { code: "attachment.unreadable", error: detail, params: { detail } });
|
||||
}
|
||||
const res = await fetch(this.base + "/attachments", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ mime: blob.type, name: name ?? "", data }),
|
||||
});
|
||||
if (!res.ok) await SseHttp.fail("/attachments", res);
|
||||
return (await res.json()) as Attachment;
|
||||
}
|
||||
|
||||
dropRefs(paths: string[]) {
|
||||
return this.post0<DroppedRef[]>("/drop", { paths });
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,18 @@ export interface VersionEntry {
|
||||
version: string;
|
||||
tag: string;
|
||||
publishedAt: string;
|
||||
hasNotes: boolean;
|
||||
current: boolean;
|
||||
older: boolean;
|
||||
}
|
||||
|
||||
// One release's notes. cached says only where they came from.
|
||||
export interface VersionNotes {
|
||||
version: string;
|
||||
markdown: string;
|
||||
cached: boolean;
|
||||
}
|
||||
|
||||
// err rides alongside the data: an unreachable catalog must not hide which
|
||||
// version is running.
|
||||
export interface VersionHub {
|
||||
|
||||
Loaded 100 of 835 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user