Compare commits

...
Author SHA1 Message Date
yhh 50f10f9419 chore(tools): tighten the computer tool descriptions to fit the schema budget
The window parameter on computer_read and computer_act put the built-in schemas 14
tokens over their budget. Shorten wording in the two descriptions instead of
widening the budget, and regenerate docs/TOOL_CONTRACT.md.
2026-10-11 16:49:56 -07:00
yhh 68c1f9d551 Merge studio 2026-10-11 16:47:10 -07:00
YHH 5e86094c4f Merge pull request #12594 from esengine/chore/remove-windows-update-helper
refactor(update): remove the unused Windows update helper from studio
2026-10-11 16:46:50 -07:00
YHH 3a9e5e4fc1 Merge pull request #12578 from esengine/fix/upgrade-npm-installs
fix(upgrade): leave npm-installed binaries to npm
2026-10-11 16:44:45 -07:00
yhh 8d869198bd Merge studio into chore/remove-windows-update-helper
The baseline had diverged from studio's. Took studio's baseline.json and
re-ran repolint -update, which only lowered budgets for the code this change
removes (70 lowered, 14 entries removed, none raised).
2026-10-11 16:39:33 -07:00
YHH 35e65c2ef2 Merge pull request #12555 from esengine/perf/boot-explicit-inputs
perf(boot): bind every storage root Build resolves to its stated home
2026-10-11 16:34:41 -07:00
yhh 980b34aa5f chore(i18n): keep the message catalogs at the 800-line ceiling
The managed-install notice added a line to messages_en.go and messages_zh.go and
pushed both over the ceiling. Drop one separator line from each.
2026-10-11 16:27:52 -07:00
yhh 6980dcfdc0 fix(upgrade): leave npm-installed binaries to npm
reasonix upgrade overwrote the prebuilt binary inside node_modules, so
npm kept recording the old version (npm outdated -g, npm ls) while the
binary reported the new one. An executable that lives in an npm package
(bin/ next to a package.json naming reasonix or @reasonix/cli-*) is now
refused before any download, with the npm command to run instead.
2026-10-11 16:26:03 -07:00
yhh 88706b8c97 chore(update): tidy what the removed Windows helper left behind
The no-op main builds on every platform but Linux so Windows no longer has an
empty package, fixes its typo, and drops two comments that still described the
Windows helper.
2026-10-11 15:57:57 -07:00
yhh 6694713cf9 refactor(update): remove the Windows update helper and the code only it reached
Studio on Windows runs the downloaded installer directly (Line.RunInstaller);
nothing builds or starts reasonix-update-helper.exe there. Delete its Windows
sources and tests, the WindowsHandoff staging code, the Windows payload
manifest and its sign subcommand, and move the repair/installlayout entry
points that only that helper called into test support.
2026-10-11 15:57:26 -07:00
yhh f21a6d1791 fix(config): judge a grant against the home the package already resolves
The process-input gate counts a direct os.UserHomeDir call; folder grants now use
the package's injectable osUserHomeDir like the other home lookups here.
2026-10-11 15:57:01 -07:00
yhh c349bab79e perf(boot): bind every storage root Build resolves to its stated home
A Build handed Options.Home still put three storage roots under the
process's home: isolated and best-of-n worktrees, browser profiles, and
the missing-reasoning recovery state. Each now resolves through the
build's config.Roots, which is the process binding whenever no home is
stated, as in every production frontend.

The new process-input repolint rule ratchets direct reads of the process
environment, working directory and user directories below
internal/frontend, so later slices can only move that count down.

40 boot tests now state their home and workspace instead of mutating the
process, and run in parallel. The isolation effect test runs two
sessions with different homes side by side and fails without the
binding fix.

The provider registry now holds a read-write lock: parallel Builds
register and resolve kinds concurrently, which -race reported as a
race and could crash with a concurrent map write.

Refs #12531
Refs #12521
2026-10-11 15:55:36 -07:00
YHH a3c025cc41 Merge pull request #12641 from esengine/fix/install-source-local-mcp-bound
fix(installsource): bound a local .mcp.json and name the cause of a refused install
2026-10-11 15:51:37 -07:00
YHH d6116a227e Merge pull request #12592 from esengine/perf/config-retired-keys-single-read
perf(config): run the retired-key migrations over one read of each config
2026-10-11 15:50:37 -07:00
YHH b2af298cba Merge pull request #12615 from esengine/fix/shell-discovery-bounded-12473
fix(sandbox): bound shell discovery as a whole so a stalled lookup cannot hold the kernel handshake
2026-10-11 15:47:10 -07:00
yhh da0e5942fb chore(installsource): state why the bounded manifest read is not a path injection
The path is a manifest inside the source the person asked to install, and the
read is capped at the fetch limit. The CodeQL alert on the open is a false
positive for this call; say so where the open is.
2026-10-11 15:33:37 -07:00
YHH c33f7fefc0 Merge branch 'studio' into perf/config-retired-keys-single-read 2026-10-11 15:27:58 -07:00
YHH a9dde35b70 Merge branch 'studio' into feat/computer-ambiguous-window-target 2026-10-11 15:27:45 -07:00
YHH 718c724aec Merge branch 'studio' into fix/shell-discovery-bounded-12473 2026-10-11 15:27:04 -07:00
YHH fda72c0730 Merge branch 'studio' into fix/install-source-local-mcp-bound 2026-10-11 15:26:38 -07:00
YHH fdabdeb34f Merge pull request #12645 from esengine/feat/provider-presets
feat(providers): offer the kernel's curated presets when adding a source
2026-10-11 15:11:52 -07:00
YHH c093e4f8ff Merge pull request #12598 from esengine/feat/turn-date-anchor
feat(control): tell the model the calendar date on the turn tail, once per day
2026-10-11 15:07:57 -07:00
yhh d613bda202 fix(studio): name the preset gallery and install form actions in the registry
The rebase onto studio put the unnamed-input surface three over its ceiling and
left provider.preset unregistered. The search, name and key inputs now carry the
provider.draft action with a value, and the gallery and install form buttons are
registered.
2026-10-11 15:04:23 -07:00
yhh e71e3330aa feat(providers): create a curated preset in the kernel from the add form
POST /providers/presets/install builds the entries a preset declares, whole
(catalog, windows, vision models, thinking shape, PresetID and version) with the
person's name and key, through the same storeProviderEntries the manual add
uses; the add form lists the kernel's presets grouped by host, searchable with
the form's own input style, and installs through that call.
2026-10-11 14:59:45 -07:00
yhh 211eb4cbf4 feat(control): tell the model the calendar date on the turn tail, once per day
The model cannot read the host clock, so a session left overnight keeps
reasoning from the date it started on. The date (with weekday and UTC offset)
is now a turn block owed through a projectionDebt: delivered on the first real
turn, absent on later turns of the same day so their bytes are identical,
re-owed on the first turn after the date changes and after a fold. It never
touches the cache-stable prefix. The clock is an explicit input
(control.Options.Clock); the boot assembly supplies the system clock and a
controller built without one tells the model nothing.
2026-10-11 14:56:00 -07:00
YHH ee5c0290a7 Merge pull request #12589 from esengine/fix/provider-remove-report
feat(providers): report the roles a removal moved or cleared and prune provider_access
2026-10-11 14:42:09 -07:00
YHH f28fc07d08 Merge pull request #12560 from esengine/fix/readiness-anchor-skips-prose
fix(readiness): prose written after a passing check owes no second run
2026-10-11 14:37:26 -07:00
yhh 78f962ac31 feat(providers): report the roles a removal moved or cleared, drop the name from provider_access, bind the failure line to its service
RemoveProvider now returns which roles it handed to another service and which it
emptied, and also removes the name from desktop.provider_access. The remove
endpoint carries that report and the provider detail pane shows it, so a
removal that switches vision, advisor or decision off says so. A failure after
the removal was already written is shown where the service used to be instead of
in the detail of whichever service is selected next.
2026-10-11 14:29:47 -07:00
yhh 2fc75feb4e fix(readiness): a still-running background job is an unseen writer
A job the session started and has not collected writes into the tree
without any receipt, so prose after a passing check was waived as if no
writer could still change files. The contract now counts a running job of
the turn's session beside tool hooks and extension sidecars.
2026-10-11 14:26:22 -07:00
yhh b135b676a5 fix(readiness): prose written after a passing check owes no second run
The generic check was measured from the latest surviving write, so a
README or CHANGELOG edit after the tests passed sent the turn back to
re-run them. The anchor now steps over a trailing run of prose whose
extent the host established, when no check is declared and the role is
not Delivery, and only while a check stands passing over both the latest
change beyond prose and the anchor the turn would have had without the
prose. The prose judgement is the one #12520 introduced, now read per
receipt by both the whole-ledger waiver and this one.

A named write follows links, so its receipt now also names the file a
link resolved to; otherwise writing code through a .md link and removing
the link before the end of the turn would read as prose.

A configured tool hook, or an extension sidecar on a point every tool
call passes (tool.before, permission.decision, tool.after), runs outside
the bash sandbox and names no paths, so the check contract carries that
fact and neither prose waiver applies while one is present.
2026-10-11 14:26:22 -07:00
YHH 1bff13835e Merge pull request #12570 from esengine/fix/provider-probes-redirect-guard
fix(providers): keep key-carrying provider requests on the configured origin
2026-10-11 14:13:07 -07:00
esengine 11aa4014d0 fix(providers): keep key-carrying provider requests on the configured origin
Provider clients took netclient.NewHTTPClient, so a redirect from the
configured address was followed by Go's default policy and the key went with
it. Add redirectguard.StayOnOrigin, build the probe, provider, System One,
balance and doctor clients through endpointclient.New, and guard the default
clients of the model listing, balance and System One calls. A refused redirect
is a permanent error that names the target host and reaches the user as
redirect_refused instead of a network fault retried ten times.
2026-10-11 14:00:48 -07:00
YHH 0807e52c2d Merge pull request #12742 from esengine/fix/objectcache-racy-windows
test(trustedstate): pin the clock and the file time in the racy-window test
2026-10-11 11:39:37 -07:00
yhh 278e06b2bc test(trustedstate): pin the clock and the file time in the racy-window test
The test relied on a freshly written file's modification time being inside the
20ms window of the process clock. A file system stamps from its own clock, which
on a loaded Windows runner can lag the process clock by more than the window, so
the object was served from memory and the leg failed intermittently. The store
clock and the file time are now both set, which is what the test is about.
2026-10-11 11:30:22 -07:00
YHH 5be5f1ea14 Merge pull request #12741 from esengine/fix/win-tree-escape-test
test(serve): use a Windows-legal directory name in the tree JSON escaping test
2026-10-11 11:08:41 -07:00
yhh bdc108ec1c test(serve): use a Windows-legal directory name in the tree JSON escaping test
Windows refuses < and > in a file name, so TestTreeWriteKeepsJSONContentTypeAndEscaping
failed at mkdir on the Windows leg. & is the other character the JSON encoder
escapes, so the same assertion holds there.
2026-10-11 10:59:08 -07:00
YHH 0ac9826a9e Merge pull request #12716 from esengine/docs/studio-2.36.0-notes
docs(release): Studio 2.36.0 notes
2026-10-11 10:37:25 -07:00
yhh 49a967ca8f docs(release): add the changes merged since the first draft to the 2.36.0 notes 2026-10-11 10:30:39 -07:00
yhh 905e973bd5 docs(release): Studio 2.36.0 notes 2026-10-11 10:29:54 -07:00
YHH 777dfd978d Merge pull request #12721 from esengine/feat/receipt-display
feat: the receipt card is hidden by default; a line says what was left unproven; visibility is a kernel setting
2026-10-11 10:20:42 -07:00
YHH 23b6a565df Merge pull request #12648 from esengine/ui/workbench-start-12481
feat(studio): opening the workbench offers a start page instead of opening a browser
2026-10-11 10:16:50 -07:00
YHH ce634f0683 Merge pull request #12740 from esengine/fix/storage-move-live-writes
fix(storage): catch up on writes made during a move and say why a target is refused
2026-10-11 10:15:44 -07:00
YHH 4161a2e62d Merge branch 'studio' into feat/receipt-display 2026-10-11 10:07:42 -07:00
YHH 5281704297 Merge branch 'studio' into ui/workbench-start-12481 2026-10-11 10:07:28 -07:00
yhh 8dc28786e8 fix(storage): catch up on writes made during a move and say why a target is refused
A move copies the root while the app keeps running and writing to it. The
verification pass hashed every file with no progress report, failed the whole
move on the first file that had changed since it was copied, and the commit
then deleted the old folder even when the app had written to it since.

Verification now reports bytes and files as it goes, copies again what changed
(bounded rounds, then ErrSourceChanging), and the old folder is kept when it
was written to after the proof. A refused target names what it holds that the
data does not own.
2026-10-11 10:06:49 -07:00
YHH 251d6e48eb Merge pull request #12739 from esengine/fix/i18n-size-ceiling
chore(i18n): keep i18n.go at the 800-line ceiling
2026-10-11 10:01:13 -07:00
YHH b612222937 Merge pull request #12500 from chuiwenwei/test/studio-corpus-policy-registry
test(e2ebench): require every task corpus to declare its authoring guards
2026-10-11 09:56:51 -07:00
yhh 4a002c54fb feat: the delivery receipt card is hidden by default; one line says what was left unproven; its visibility is a kernel setting
Replaces the stack #12601 (hide by default), #12637 (hint line) and #12643
(kernel setting). [ui] show_receipt (default off) is read and written through the
controller (ReceiptSettings, GET/POST /receipt-settings) like show_changed_files;
Studio keeps a per-kernel answer and carries an old browser-local on across once;
the TUI draws a line, or the card when the key asks for it. The kernel grades the
receipt (Severity) and the hidden card leaves one line for a graded one.
2026-10-11 09:55:16 -07:00
yhh 621e381736 chore(i18n): keep i18n.go at the 800-line ceiling
#12655 added one Messages field and pushed the file to 801 lines, which fails
repolint on the baseline. Drop one separator line between two field groups.
2026-10-11 09:53:16 -07:00
yhh 476942bb93 feat(studio): opening the workbench offers a start page instead of opening a browser
The column opened with a browser tab already in it, whichever way it was
asked for. It now opens on a start page that offers the file explorer, the
changed files and the browser, and a browser exists only once it is chosen.
A page a followed link already has on its way is still shown as before.
2026-10-11 09:45:20 -07:00
YHH f1434b7ae4 Merge pull request #12647 from esengine/ui/sidebar-rail-unify-v2
fix(studio): one sidebar toggle that does not move, a conversation item that does something, one header per run of replies
2026-10-11 09:38:16 -07:00
YHH 8a421f14d2 Merge pull request #12655 from esengine/fix/goal-resume-starts-turn
fix(control): resuming a Goal runs it, and a reply to a blocked Goal puts it back in pursuit
2026-10-11 09:35:48 -07:00
YHH fe8e6dc78f Merge pull request #12651 from esengine/fix/handback-ends-turn
fix(readiness): an explicit hand-back ends the turn instead of being continued
2026-10-11 09:34:52 -07:00
YHH 7c5e35ea1e Merge pull request #12654 from esengine/fix/refused-input-notice-code
fix(control): a refused submission carries a notice code the queueing frontend can word
2026-10-11 09:34:24 -07:00
YHH dc3c84f903 Merge pull request #12607 from esengine/fix/windows-frontend-test-env
test(studio): give async queries room on a loaded machine
2026-10-11 09:33:24 -07:00
yhh 0c34adf412 fix(studio): one sidebar toggle that does not move, a conversation item that does something, and one header per run of replies
The sidebar had three toggles in two screen positions (header button,
title-bar button that shifted with the sidebar, edge grip), a rail
conversation item that did nothing from the conversation view, quick entries
repeated in the sidebar while the icon rail held them, and an R mark plus a
name on every reply. The title-bar button is now fixed to the window and is
the only button, the header one is gone; the conversation item opens and
closes the workspace column and returns home from settings; the sidebar's
quick entries show only when the rail is not drawn and the rail gains the
files entry; replies from one model after the first carry no header.
2026-10-11 09:28:42 -07:00
YHH c8fbbf70f4 Merge pull request #12714 from esengine/feat/fold-turn-work-12671
feat(studio): fold a turn's work under one line, off by default
2026-10-11 09:19:44 -07:00
yhh 86f6486b9b feat(studio): fold a turn's work under one line, off by default
[ui] fold_turn_work (default false) is a kernel setting read through the
controller like the changed-files pill. When it is on, a finished turn
shows its last sentence and gathers the tool calls, thinking and the
sentences in between into one closed line with the call count and any
failure; the line opens on click and keeps its state, and the turn that is
still running is never folded.
2026-10-11 09:08:32 -07:00
YHH 96ed4ea786 Merge pull request #12616 from esengine/fix/narrow-scaled-shell-rows-12479
fix(studio): shell rows no longer collide at high scale in a narrow window
2026-10-11 09:04:57 -07:00
YHH 5c91bd0a72 Merge pull request #12674 from XTLine/fix/studio-machine-settings
fix(studio): keep machine pages local / 修复远程窗格下本机页面错绑
2026-10-11 09:03:33 -07:00
YHH adc5167911 Merge pull request #12495 from chuiwenwei/docs/context-retrieval-v2-result
docs(context-retrieval): record the v2 Experiment I result in Status
2026-10-11 09:02:37 -07:00
yhh fc84278599 test(e2ebench): use slices.Contains and strings.Builder in the corpus policy test
Clears the two modernize findings golangci-lint reports in this test.
2026-10-11 09:01:27 -07:00
YHH 74e457467c Merge pull request #12494 from chuiwenwei/feat/contextbench-usage-in-results
feat(bench): account for what each context-retrieval run costs
2026-10-11 08:59:15 -07:00
yhh e6ca850e1d fix(studio): the title bar folds into one button instead of colliding at high scale
At a couple of hundred CSS pixels the composer's model group could neither
shrink nor wrap and the title bar's right-hand controls left its title no
room, so rows ran over each other. Below 300px of room (the width at which
the five controls leave the title under its 4em floor) the bar gathers phone,
find, theme, sandbox and browser under one button; each stays one press away.
The model group wraps, the tabs shrink, the meter row drops its cache figure
and the placeholder keeps to one line at the narrowest widths. The size
slider's fallback range parks the thumb mid-track while no range is announced.
2026-10-11 08:56:58 -07:00
YHH c41ad00d55 Merge branch 'studio' into test/studio-corpus-policy-registry 2026-10-11 08:41:11 -07:00
YHH 44feacc865 Merge branch 'studio' into docs/context-retrieval-v2-result 2026-10-11 08:41:01 -07:00
YHH 6b361d37e9 Merge branch 'studio' into feat/contextbench-usage-in-results 2026-10-11 08:40:49 -07:00
YHH 2f86d06a78 Merge pull request #12646 from esengine/ui/signal-rail-explained-12453
fix(studio): say what the signal rail is for and why its marks do not move
2026-10-11 08:37:53 -07:00
yhh 9a3d5bf432 fix(studio): say what the signal rail is for and why its marks do not move
The run analysis drew a rail labelled only 'Signal rail' and 'Shares the
timeline below'. A line now says it holds the run's records that have no
duration and are not tool calls, placed at the moment they happened, so the
position is the time and cannot be dragged.
2026-10-11 08:30:29 -07:00
YHH a5bc1bbefa Merge pull request #12715 from esengine/feat/settings-extra-folders-11527
feat(studio): manage a project's extra writable folders in Settings
2026-10-11 08:26:19 -07:00
YHH 68ba644676 Merge pull request #12717 from esengine/fix/extra-root-drive-letter
fix(config): an extra root must name a drive letter on Windows
2026-10-11 08:21:09 -07:00
yhh 25efbb20a9 fix(studio): declare the extra-folder settings actions in the action registry 2026-10-11 08:17:34 -07:00
YHH 3a7617813b Merge pull request #12702 from esengine/feat/task-done-sound
feat(notify): an optional chime for turn done, approvals and questions
2026-10-11 08:16:27 -07:00
yhh 49c116f771 fix(studio): the folder picker only fills the field; say what adding and removing do
Choosing a folder in the system dialog no longer grants it; Add does. The
panel states that the agent can change and delete anything in an added
folder and that panes already open narrow only on rebuild. A failed read
shows a retry, refusals use the error box, and the project row is laid
out like the other facts.
2026-10-11 08:16:15 -07:00
yhh 08a214570a feat(studio): manage a project's extra writable folders in Settings
Settings > Tools and permissions > Sandbox lists the project folder and
the extra folders granted to it, and adds or removes them through the
existing /workspace/roots routes. The kernel's refusals are worded by
their codes. The panel says that 'allow everything' only decides whether
to ask and does not widen where writes can land. No folder is added by
default.
2026-10-11 08:16:15 -07:00
YHH ddccefef09 Merge pull request #12720 from esengine/ui/running-tool-detail-11985
fix(studio): a running call with no output yet opens and says what it is doing
2026-10-11 08:14:53 -07:00
yhh 626068c473 fix(studio): declare the notification sound switch in the action registry 2026-10-11 08:08:13 -07:00
yhh 2ebd444b2d feat(notify): an optional chime for turn done, approvals and questions
[notifications] sound = true plays a short system chime for the events already
selected, independent of system notifications and of their muting, so a
minimised or backgrounded window is still audible. Off unless asked for. The
Windows, macOS and Linux senders implement Chime; the Settings notifications
block gets its own switch.
2026-10-11 08:07:50 -07:00
XTLine a6bbceffbd merge(studio): inherit CI fixes and isolate the job-list fixture
Integrate Studio 71650fe7, including upstream desktop modernization 85460ae14 and the job-list test convention from 2b5f6448. Keep the latter test-only setting unchanged and use the supported scripted resolver with explicit selection and launch assertions. The only authored delta from the automatic merge is 17 additions and 10 deletions in that fixture; no coverage workflow or product sandbox changes.
2026-10-11 23:06:49 +08:00
yhh c842f014f4 fix(studio): a running call with no output yet opens and says what it is doing
In the activity group a running call that had printed nothing was a row that
did nothing when clicked, and a long command was clipped to one line, so a
call waiting out a timeout looked frozen. Such a row now opens to its input,
says it has no output yet and for how long it has run, names the command's
own timeout when it carries one, and its hover text holds the whole command.
2026-10-11 08:00:44 -07:00
YHH 18965b946b Merge pull request #12712 from esengine/fix/upload-size-guard
fix(studio): one size guard before every upload is read into memory
2026-10-11 07:59:52 -07:00
YHH cc4820ca9a Merge branch 'studio' into fix/extra-root-drive-letter 2026-10-11 07:59:46 -07:00
YHH d50a477297 Merge pull request #12704 from esengine/fix/local-model-check-timeout
fix(providers): name a model check that timed out against a local address and give it a longer wait
2026-10-11 07:56:27 -07:00
YHH d16404fe19 Merge pull request #12688 from esengine/feat/workspace-sessions-show-more
feat(studio): show a workspace's true session total and page in older sessions
2026-10-11 07:56:12 -07:00
YHH 71650fe7fd Merge pull request #12737 from esengine/fix/joblist-effect-test-sandbox
test(boot): turn the bash sandbox off in the running-jobs effect test
2026-10-11 07:51:51 -07:00
yhh 17bc7388d9 fix(studio): one size guard before every upload is read into memory
Attachments, wallpapers and theme packs were each turned into base64 before the
kernel could refuse them. One guard in the port checks the kernel's cap first,
using the image cap for pictures, and a Go test pins every number to the kernel's.
2026-10-11 07:43:51 -07:00
yhh 2b5f644882 test(boot): turn the bash sandbox off in the running-jobs effect test
The test starts a background bash job through the real Build assembly. On a
host without bubblewrap the sandbox refuses to run unconfined, so the job
never starts and the coverage leg fails there while passing on a developer
machine. Its sibling effect tests already declare [sandbox] bash = "off".
2026-10-11 07:42:56 -07:00
YHH 7d8164a281 Merge pull request #12707 from esengine/fix/nav-hint-delay-12692
fix(studio): icon-rail hints wait for a short hover before they appear
2026-10-11 07:29:32 -07:00
YHH 5f0bb7d845 Merge pull request #12719 from esengine/feat/logs-path-in-about
feat(studio): show where the logs and abnormal-exit records are kept
2026-10-11 07:05:11 -07:00
YHH fc7fbedf83 Merge pull request #12710 from esengine/fix/provider-400-wording
fix(i18n): a 400 says the service refused the request and that its reason follows, not that this is a program defect
2026-10-11 07:01:31 -07:00
YHH 34089143fc Merge pull request #12619 from esengine/fix/symlink-write-extent
fix(readiness): record where a named-path write through a symlink landed
2026-10-11 07:01:25 -07:00
YHH 5e196bc539 Merge pull request #12718 from esengine/chore/desktop-lint-stringsseq
chore(desktop): range over strings.SplitSeq in the packaged-names test
2026-10-11 06:52:34 -07:00
yhh e28123cf80 feat(studio): a workspace's true session total, paged by view and bounded
/tree windows each workspace by the request's scope (active, archived) and word,
always builds pinned active conversations, and reports the total, archived and
remaining counts for the whole folder. The sidebar counts from the kernel, so the
chips do not move with the window, an archived conversation far outside it is
reachable, search is filtered on the kernel and paged, and a folder holds at most
300 rows. The remote machine list shows the far kernel's total instead of a
truncated count.
2026-10-11 06:50:36 -07:00
yhh 9d3424f3bb fix(config): an extra root must name a drive letter on Windows, and a delegated write is pinned end to end
Windows accepted \??\C:\... because only a leading double backslash was
refused. A root's volume must now be a drive letter and a colon; UNC, \\?\, \\.\
and NT object paths are all refused. Adds a boot-level test where a delegated
child writes only a granted folder.
2026-10-11 06:48:31 -07:00
yhh 8f4ccbbb2b feat(studio): show where the logs and abnormal-exit records are kept 2026-10-11 06:48:23 -07:00
YHH 0d4e37682a Merge pull request #12706 from esengine/fix/wheel-scroll-jump-12691
fix(studio): scrolling up no longer jerks or jumps to another message
2026-10-11 06:47:19 -07:00
yhh 7cf1de5381 fix(sandbox): bound shell discovery as a whole so a stalled lookup cannot hold the kernel handshake
PowerShell candidates were launch-probed one after another with a 10 s
timeout each, and nothing bounded host construction or the lookups around
the probes. On a machine where Store aliases, a PATH entry or a registry
read stall, first start sat in shell discovery past the shell's 60 s
handshake wait.

PowerShell now shares bash's hedged search and budget, probe commands
cap their post-kill wait, and the whole resolution (host construction
included) gives up after both budgets plus a hedge: it uses a bash
already proven in this process, else the bare name with a warning that a
restart usually recovers.
2026-10-11 06:39:22 -07:00
yhh 85460ae14e chore(desktop): range over strings.SplitSeq in the packaged-names test
The modernize stringsseq check flagged strings.Split in a range loop; the loop
reads the same lines.
2026-10-11 06:38:30 -07:00
YHH 61ef913d4c Merge pull request #12697 from esengine/feat/refused-calls-turn-tail
feat(control): tell the model which calls the host refused, once, at the next turn
2026-10-11 06:36:39 -07:00
YHH aad83aaae9 Merge pull request #12713 from esengine/fix/unapplied-steer-row-12458
fix(studio): a queued line the kernel could not apply leaves the transcript
2026-10-11 06:34:50 -07:00
YHH 234db5d11f Merge pull request #12709 from esengine/fix/linux-tray-menu-repaint-stable
fix(studio): the tray menu is replaced only when its content changes
2026-10-11 06:33:40 -07:00
XTLine da98e04381 merge(studio): reconcile machine settings with runtime updates 2026-10-11 21:32:04 +08:00
YHH a8d9c95631 Merge pull request #12703 from esengine/feat/composer-full-width
feat(studio): an opt-in line width that widens the composer with the transcript
2026-10-11 06:31:01 -07:00
YHH 34ab4297db Merge pull request #12711 from esengine/fix/attachment-size-precheck
fix(studio): refuse an oversize attachment before reading it into memory
2026-10-11 06:30:19 -07:00
yhh 3e60275fb0 fix(studio): scrolling up no longer jerks or jumps to another message
Each transcript row asked the browser to skip rendering it while off screen
with an estimated height, and swapped the estimate for the measured height
the first time the row scrolled into view. The page shrank under the wheel
(12975px to 11217px in the probe), so the content jumped, once by 1858px.
Rows that are far off screen are already unmounted by their block, which
holds the measured height, so the estimate had no job left.
2026-10-11 06:28:06 -07:00
yhh bef076f523 fix(studio): a queued line the kernel could not apply leaves the transcript
unapplied_steer names the queue entry the kernel took out of the turn
without the model reading it and holds for a retry. The row that said it
was waiting stayed forever, while the queue panel, which the kernel
authors, showed the same line as blocked and retryable. The row now goes
when that notice names it; the notice card stays.
2026-10-11 06:22:46 -07:00
yhh 3b5a389193 fix(i18n): a 400 says the service refused the request and that its reason follows, not that this is a program defect
The 400 text told every reader it was probably a bug. The host cannot know that:
the reason the service gave is appended, so the preface now says whose refusal
it is, where the reason is, and what can be tried, without promising it works.
2026-10-11 06:22:12 -07:00
YHH 4a33d64e2b Merge pull request #12700 from esengine/feat/mcp-prompt-args-completion
feat(completion): show an MCP prompt's declared arguments in the slash menus
2026-10-11 06:22:09 -07:00
YHH dd24c520db Merge pull request #12708 from esengine/feat/more-font-choices-12693
feat(studio): suggest many more interface and code fonts
2026-10-11 06:21:23 -07:00
yhh 624182edbd fix(studio): redraw the tray checkbox after a refused save and retry a menu that failed to draw 2026-10-11 06:18:57 -07:00
yhh 23e36e9a99 fix(studio): the tray menu is replaced only when its content changes
The poll re-ran setContextMenu every five seconds with an identical menu.
Where the menu is handed to the desktop's indicator over D-Bus, replacing
it closes one the person has open. It is now rebuilt only when the
sentence, the words or the close-to-tray setting differ.
2026-10-11 06:18:57 -07:00
yhh dfefd3e765 fix(readiness): record where a named-path write through a symlink landed
The prose waiver judged paths by what the disk showed at the end of the turn,
so a documentation-named symlink to a code file that was written through and
then removed counted as a deleted prose file. The resolved target is now
recorded on the write receipt while the link still resolves.

Refs #12524
2026-10-11 06:15:10 -07:00
yhh f9f1b0b28f fix(studio): refuse an oversize attachment before reading it into memory
attach() turned the whole file into an ArrayBuffer, a binary string, base64 and a
JSON string before the kernel could refuse it at 25 MB, so a modpack-sized
archive could take the renderer down. The size is checked first and refused with
the kernel's own attachment.too_large identity.
2026-10-11 06:14:48 -07:00
yhh 48aa562625 fix(studio): icon-rail hints wait for a short hover before they appear
The rail's hints were drawn the instant the pointer touched an icon, so a
pass across the column flashed every label. They now appear after a 0.3s
rest (--tip-delay; VS Code waits 250ms), and keyboard focus still shows its
hint at once.
2026-10-11 06:14:00 -07:00
YHH 83a1a3fa01 Merge pull request #12705 from esengine/fix/host-turn-announces-start
fix(control): a queued host turn announces its start
2026-10-11 06:13:19 -07:00
YHH 827a78ce7a Merge pull request #12698 from esengine/fix/completion-reserve-in-window
fix(agent): keep a window-proportional margin for the completion and recover a codeless 400 that counts it
2026-10-11 06:11:29 -07:00
yhh f6fdbcdbd9 feat(completion): show an MCP prompt's declared arguments in the slash menus 2026-10-11 06:07:13 -07:00
yhh fa98929bfa fix(config): a saved full-all width survives the render
The [desktop] renderer wrote conversation_width only for full, so a full-all
choice reverted to standard on the next start. It now writes every non-standard
width; a render-and-reparse test, a page-attribute test and narrow-viewport
checks cover the new choice.
2026-10-11 06:06:40 -07:00
YHH c5477a2ead Merge pull request #12690 from esengine/fix/rebuild-dispatcher-notices
fix(boot): a rebuilt dispatcher keeps the missing-sidecar notice
2026-10-11 06:05:33 -07:00
YHH 455f9e6cf5 Merge pull request #12701 from esengine/fix/feedback-env-windows-product
fix(studio): the feedback form names Windows 11 from its build number
2026-10-11 06:05:21 -07:00
yhh 53e2d91f0a feat(studio): suggest many more interface and code fonts
The font fields took any family the machine has but suggested only a dozen,
and on Windows few of those are installed. The suggestions now cover the
common CJK, Latin and programming faces, filtered to the ones the machine
really has, as before. Nothing is chosen by default.
2026-10-11 06:05:15 -07:00
yhh 17aaad209d feat(studio): an opt-in line width that widens the composer with the transcript
The full measure widens the transcript only, by design. A third choice,
full-all, moves the composer's column with it; the default and the existing
full measure are unchanged.
2026-10-11 06:04:24 -07:00
yhh f6eb618bf8 fix(control): a queued host turn announces its start
A host-authored inbox item (a background job's completion, for one) ran
as a synthetic turn that opens no boundary and so emitted answer text and
a turn end with no turn start. A client that never heard the start kept
the send arrow, showed no stop button and counted nothing running, and
its status poll stayed off, so it could not stop a turn that was
running. The queued host turn now announces its own start.
2026-10-11 05:59:41 -07:00
yhh ad5ac1f6a9 feat(control): tell the model which calls the host refused, once, at the next turn
A refusal reached the model only in that call's result, which compaction can
fold away, and the posture refusal did not say not to retry. Calls refused by
the host (any permission refusal code) are now owed to the next real turn as a
refused-calls block, delivered once like the other turn projections, and the
posture refusal says the call and its rewordings will not be approved.

Refs #12253
2026-10-11 05:59:40 -07:00
YHH 682efd0516 Merge pull request #12699 from esengine/fix/windows-toast-click-12287
fix(notify): a clicked Windows toast brings Studio's window forward
2026-10-11 05:58:18 -07:00
yhh 8ed9f57f16 fix(providers): name a model check that timed out against a local address and give it a longer wait 2026-10-11 05:54:58 -07:00
yhh e5bedcb7b9 fix(agent): keep a window-proportional margin for the completion and recover a codeless 400 that counts it
On a 1M window the fixed 8K output margin was smaller than the prompt
estimate's own error, so a prompt a percent above its estimate plus a 64K
completion exceeded the window and the provider refused every turn. The margin
is now max(8K, window/32), and a codeless 400/413 is classified as an overflow
when the estimate plus the requested completion is past the window, so the
turn folds and replays instead of repeating the refusal.

Fixes #12694
2026-10-11 05:54:53 -07:00
YHH c6ab758469 Merge pull request #12661 from esengine/feat/ask-timeout
feat: skip an unanswered ask after a user-set wait, and tell the model it was a timeout
2026-10-11 05:54:07 -07:00
yhh 23999c4aa2 fix(studio): the feedback form names Windows 11 from its build number
Windows 11 reports NT 10.0, so the environment block read like Windows 10.
The form now shows 'Windows 11 (10.0.26200)' for builds from 22000; the
version string that is sent is unchanged.
2026-10-11 05:51:08 -07:00
YHH 0d8abb97ef Merge pull request #12640 from esengine/feat/workspace-extra-roots
feat(control): grant a workspace extra folders, with typed refusals
2026-10-11 05:47:13 -07:00
YHH 6148b172da Merge pull request #12650 from esengine/fix/dock-column-top-12617
fix(studio): the docked browser column starts at the top of the pane
2026-10-11 05:44:59 -07:00
yhh 3a93f38c94 fix(notify): a clicked Windows toast brings Studio's window forward
The kernel sent toasts through the Windows Runtime but never installed an
activation callback, so a click did nothing. It now registers one; the
host forwards it as a show act over the handshake pipe and the shell's
act handler (now its own module) shows and focuses the window.
2026-10-11 05:44:32 -07:00
yhh 7a6daf34a8 feat: skip an unanswered ask after a user-set wait, and tell the model it was a timeout
[agent] ask_timeout_seconds (user file only, default 0 = wait) bounds how long the
agent's own ask question waits. On expiry the host settles the card on every
frontend with a timed_out decision receipt and returns the model a result that
names the timeout, not an answer. External forms are never skipped.
Studio settings and the terminal's /ask-timeout drive the same controller
setting.
2026-10-11 05:44:21 -07:00
YHH 266ae5bfff Merge pull request #12653 from esengine/perf/turn-reads-12534
perf(state): read each session sidecar once per transaction and remember verified trusted objects
2026-10-11 05:42:49 -07:00
yhh ca730caab2 feat(control): grant a workspace extra folders, vetted and treated as workspace
Extra roots are the project grant's folders in the user's home. They are a set
of their own: write roots and the OS jail read them, the guard on Reasonix's
own state does not treat them as exemptions, the turn tail names them, and the
scan, readiness and receipt of the executor and of every sub-agent count them
as workspace. One judgement (FolderGrantRefusal, shared with workspace trust
and POST /sandbox) refuses drive roots, home and its ancestors, storage roots,
key and credential directories and the forbid list, in either direction,
comparing file identity rather than spelling, plus folders the user does not
own, UNC and device-namespace paths, and overlaps with other write roots. Every
load vets each grant again. Withdrawing a root rebuilds every pane on the
workspace, after this pane's lock is released.
2026-10-11 05:40:15 -07:00
yhh 871ccce82b fix(trustedstate): read every file-identity field so the unused check passes where it is not compared 2026-10-11 05:34:33 -07:00
yhh 70e55f0cac fix(trustedstate): openbsd takes the size-and-mtime fallback
Its Stat_t names the change time Ctim, not Ctimespec.
2026-10-11 05:33:17 -07:00
yhh 20f07162c7 perf(trustedstate): a cache hit also needs the file's inode and change time
The change time cannot be restored the way a modification time can, so an
in-place edit that restores size and mtime misses the cache. Platforms
without that identity fall back to size and time. Adds limit and
replacement tests and states the in-process memory in the package doc.
2026-10-11 05:33:17 -07:00
yhh 9bd0671ea0 perf(state): read each session sidecar once per transaction and remember verified trusted objects
A locked branch-meta read-modify-write passes what it read to the write
instead of reading again, and recording a revision no longer re-reads
what it just wrote. trustedstate.Object remembers a verified object with
its size and modification time and serves it while both are unchanged
and old enough to be meaningful; Verify always reads the bytes.
2026-10-11 05:33:17 -07:00
yhh 4a0c06448c fix(studio): the docked browser column starts at the top of the pane
The docked pane's grid kept the navigation and find rows across both
columns, so the browser column began 44px below the pane top and its own
8px padding made the gap 52px. The column now spans every row beside them;
under the scene fold the old stacked layout is kept explicitly.
2026-10-11 05:32:03 -07:00
yhh 4d3ea82f67 fix(boot): a rebuilt dispatcher keeps the missing-sidecar notice
stageDispatcher built the successor with empty options, so after a subgraph
rebuild an optional extension without a sidecar was skipped silently, right at
the moment an install or enable makes that most likely. The successor now takes
the reporting of the dispatcher it replaces.

Refs #12258
2026-10-11 05:29:37 -07:00
YHH 268b955eab Merge pull request #12687 from esengine/dependabot/npm_and_yarn/desktop/frontend-next/npm-0c84b57d5b
build(deps): bump the npm group in /desktop/frontend-next with 8 updates
2026-10-11 05:25:02 -07:00
YHH 0f175e404d Merge pull request #12686 from esengine/dependabot/github_actions/actions-690969f723
build(deps): bump the actions group with 5 updates
2026-10-11 05:24:13 -07:00
YHH 881789cfe1 Merge pull request #12681 from esengine/dependabot/go_modules/desktop/go-6a10bec2cb
build(deps): bump golang.org/x/sys from 0.48.0 to 0.49.0 in /desktop in the go group
2026-10-11 05:20:27 -07:00
YHH c17dc72fb0 Merge pull request #12630 from esengine/fix/pluginpkg-shown-locator-12489
fix(pluginpkg): show package data inside host sentences as literals
2026-10-11 04:59:45 -07:00
YHH d9bf6c59d1 Merge pull request #12669 from XTLine/fix/studio-extension-warning-categories
fix(studio): separate extension warning categories / 区分扩展告警类别
2026-10-11 04:59:35 -07:00
YHH 183ccbb141 Merge pull request #12678 from xlyy1/fix/planmode-browser-open-12508
fix(planmode): explain browser_open opt-out and offer web_fetch
2026-10-11 04:59:25 -07:00
YHH 89d3965799 Merge pull request #12677 from esengine/fix/notify-copy
fix(studio): the notification setting says when it fires, that it is off by default, and who picks the sound
2026-10-11 04:59:15 -07:00
YHH 3b8bf8985b Merge pull request #12675 from esengine/fix/windows-update-keeps-pin-12670
fix(update): start Studio's Windows installer with --updated so an update keeps shortcuts and the taskbar pin
2026-10-11 04:59:01 -07:00
YHH 9b46939729 Merge pull request #12673 from esengine/fix/steer-splits-thinking-12672
fix(studio): a line typed mid-thought no longer splits the thought into two blocks
2026-10-11 04:58:50 -07:00
YHH 56fef30a31 Merge pull request #12668 from esengine/feat/changed-files-pill
feat(studio): changed-files pill above the composer
2026-10-11 04:58:40 -07:00
YHH 77274db339 Merge pull request #12664 from par73e/codex/11700-session-list-sync
fix(studio): refresh shared session trees on kernel changes
2026-10-11 04:58:30 -07:00
YHH e735775648 Merge pull request #12667 from esengine/fix/responses-input-image-detail
fix(responses): send the schema's required detail on input_image to the official OpenAI host
2026-10-11 04:58:20 -07:00
YHH 4d3cca5e54 Merge pull request #12666 from esengine/fix/responses-input-shape-diagnostic
fix(responses): send the schema's required detail on input_image and say what shape a refused request had
2026-10-11 04:58:10 -07:00
YHH 94f9ff00e0 Merge pull request #12656 from esengine/feat/turn-duration
feat: show how long each turn ran
2026-10-11 04:58:00 -07:00
YHH b0bef02583 Merge pull request #12627 from esengine/feat/list-background-jobs
feat(tools): bash_output without a job id lists the running background jobs
2026-10-11 04:57:30 -07:00
YHH 4afd827ace Merge pull request #12571 from xxoingr/fix/mcp-json-auto-refusal
fix(installsource): keep the cause of a refused .mcp.json under kind=auto
2026-10-11 04:57:20 -07:00
YHH c875f8440b Merge pull request #12636 from esengine/fix/shared-text-policy-12483
fix(textutil): one declared policy per surface kind for package-supplied text
2026-10-11 04:57:00 -07:00
YHH 362803d710 Merge pull request #12622 from esengine/fix/model-switch-keeps-background-jobs
fix(serve): let a model switch go through while only background jobs remain
2026-10-11 04:56:45 -07:00
YHH ea8d65e152 Merge pull request #12632 from esengine/fix/receipt-host-owned-exclusion
fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned
2026-10-11 04:56:30 -07:00
YHH b17b4ae034 Merge pull request #12635 from esengine/fix/shell-launch-location
fix(sandbox): one rule for where a shell may be launched from
2026-10-11 04:56:20 -07:00
YHH 65e2fa36dd Merge pull request #12662 from esengine/fix/linux-update-helper-package-12657
fix(update-helper): accept the package the update path names, and guard it in the package
2026-10-11 04:56:10 -07:00
dependabot[bot] 66e36b1c69 build(deps): bump the npm group in /desktop/frontend-next with 8 updates
Bumps the npm group in /desktop/frontend-next with 8 updates:

| Package | From | To |
| --- | --- | --- |
| [@codemirror/language](https://github.com/codemirror/language) | `6.12.4` | `6.13.1` |
| [@codemirror/view](https://github.com/codemirror/view) | `6.43.13` | `6.43.14` |
| [katex](https://github.com/KaTeX/KaTeX) | `0.18.10` | `0.19.0` |
| [@babel/parser](https://github.com/babel/babel/tree/HEAD/packages/babel-parser) | `8.0.6` | `8.0.7` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.1` | `6.1.2` |
| [jsdom](https://github.com/jsdom/jsdom) | `30.1.1` | `30.1.2` |
| [playwright](https://github.com/microsoft/playwright) | `1.63.0` | `1.64.0` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.2` | `8.3.3` |


Updates `@codemirror/language` from 6.12.4 to 6.13.1
- [Changelog](https://github.com/codemirror/language/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/language/commits)

Updates `@codemirror/view` from 6.43.13 to 6.43.14
- [Changelog](https://github.com/codemirror/view/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codemirror/view/commits)

Updates `katex` from 0.18.10 to 0.19.0
- [Release notes](https://github.com/KaTeX/KaTeX/releases)
- [Changelog](https://github.com/KaTeX/KaTeX/blob/main/CHANGELOG.md)
- [Commits](https://github.com/KaTeX/KaTeX/compare/v0.18.10...v0.19.0)

Updates `@babel/parser` from 8.0.6 to 8.0.7
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v8.0.7/packages/babel-parser)

Updates `@vitejs/plugin-react` from 6.1.1 to 6.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/[email protected]/packages/plugin-react)

Updates `jsdom` from 30.1.1 to 30.1.2
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Commits](https://github.com/jsdom/jsdom/compare/v30.1.1...v30.1.2)

Updates `playwright` from 1.63.0 to 1.64.0
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.63.0...v1.64.0)

Updates `vite` from 8.3.2 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@codemirror/language"
  dependency-version: 6.13.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@codemirror/view"
  dependency-version: 6.43.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: katex
  dependency-version: 0.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: "@babel/parser"
  dependency-version: 8.0.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: jsdom
  dependency-version: 30.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
- dependency-name: playwright
  dependency-version: 1.64.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:56:04 +00:00
dependabot[bot] 6fb606fd53 build(deps): bump the actions group with 5 updates
Bumps the actions group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/setup-node](https://github.com/actions/setup-node) | `7.0.0` | `7.1.0` |
| [actions/upload-artifact](https://github.com/actions/upload-artifact) | `7.0.1` | `7.0.2` |
| [github/codeql-action/init](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [github/codeql-action/analyze](https://github.com/github/codeql-action) | `4.38.2` | `4.38.3` |
| [actions/download-artifact](https://github.com/actions/download-artifact) | `8.0.1` | `8.0.2` |


Updates `actions/setup-node` from 7.0.0 to 7.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/820762786026740c76f36085b0efc47a31fe5020...949feb2413d6458794dcd2491c4babbbce0c15c1)

Updates `actions/upload-artifact` from 7.0.1 to 7.0.2
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/043fb46d1a93c77aae656e7c1c64a875d1fc6a0a...cf430e030ddbb5b0abf93d22962f4752f3646cd9)

Updates `github/codeql-action/init` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `github/codeql-action/analyze` from 4.38.2 to 4.38.3
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2...24c54180a607b1449ed407dd24f251e4e9147c8d)

Updates `actions/download-artifact` from 8.0.1 to 8.0.2
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c...9000827ccba6bdab643e8b6fd33ac0654aef8333)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 7.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions
- dependency-name: actions/upload-artifact
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/init
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.38.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
- dependency-name: actions/download-artifact
  dependency-version: 8.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:55:46 +00:00
YHH 406771534e Merge pull request #12642 from esengine/fix/notice-wording-shared
fix(studio): word every placeholder notice through one shared resolver
2026-10-11 04:55:40 -07:00
YHH 4e41e669b5 Merge pull request #12620 from esengine/fix/gutter-over-scrollbar-12467
fix(studio): a column's drag handle no longer covers the scrollbar beside it
2026-10-11 04:54:50 -07:00
XTLine f3c3385bbd fix(control): tolerate unreadable currency preferences on bind
Keep the already-built quote and broadcaster projections when canonical configuration cannot be read. Warn without rewriting the recoverable file, while still enrolling valid local controllers. Cover real Adopt, Open, model, extension and workspace publication with malformed configuration, including built-in-default fallback. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:53:20 +08:00
dependabot[bot] 80835fc83d build(deps): bump golang.org/x/sys in /desktop in the go group
Bumps the go group in /desktop with 1 update: [golang.org/x/sys](https://github.com/golang/sys).


Updates `golang.org/x/sys` from 0.48.0 to 0.49.0
- [Commits](https://github.com/golang/sys/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-11 11:53:12 +00:00
XTLine 1fe77558db fix(studio): classify retired currency owners as conflicts
Preserve the controller retirement identity at the HTTP boundary with a localized 409 refusal. Exercise selection racing a real model replacement, then delegate to the retired controller and assert no config or currency mutation. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:53:00 +08:00
YHH fdab1d83ec Merge pull request #12679 from esengine/ci/pr-platform-legs
ci: run the macOS and Windows legs on pull requests only when a platform path changed
2026-10-11 04:47:00 -07:00
esengine 58f583e90b test(testenv): read the Windows legs through a substituted runs-on expression 2026-10-11 04:31:01 -07:00
YHH 24e000b195 Merge pull request #12624 from esengine/fix/restore-window-bounds-12475
fix(studio): reopen the window at the rectangle it was closed at
2026-10-11 04:23:38 -07:00
XTLine 4de056fa8b test(serve): cover currency projection across runtime rebuilds
Exercise real booted local runtimes, a remote pane, live ledger and balance projections, future wire quotes, model replacement and sequential stale calls after close. The canonical-config fixtures do not claim a successful durable settings write. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:55 +08:00
XTLine aa4287afc6 test(control): guard ordered currency binding
Verify canonical quiet enrollment, failed binding atomicity, replacement and close membership, and projection ordering against the next notice. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:49 +08:00
XTLine f01012d29f test(control): cover currency fanout and failed saves
Check full local sinks, future quotes, remote exclusion, retirement and failed-save atomicity. Retain the real HTTP currency-save assertion for CI; this sandbox cannot acquire the OS-user config lock. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:42 +08:00
XTLine f041d15a95 fix(serve): bind currency to local runtime generations
Enroll only local controllers and reconcile their quote and broadcaster projections before publication. Share the same ordered replacement path across model, extension and workspace rebuilds, and retire membership when a runtime closes. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:36 +08:00
XTLine f172ed7498 fix(control): synchronize local display currency ownership
Serialize persisted display-currency changes with local-controller membership. Quiet enrollment refreshes the exact quote context from canonical configuration; retired generations cannot save, and successful saves notify each full local sink. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:29 +08:00
XTLine 671bec66cd fix(studio): restart feedback polling for local owner changes
Bind unread polling to availability and identity of the first local machine owner. Opening or replacing that owner starts a fresh read while ordinary active-pane switches keep the polling session. Cover remote-only startup, opening local sessions and retiring the first local owner through the real App. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:21 +08:00
XTLine 646e4e32d9 fix(studio): save defaults through the selected local owner
Use the same pinned local catalogue owner for the default-model write and read. Cover a model exposed only by the second local runtime through the actual Settings picker, SSE route and HTTP catalogue validation. Retain the durable-write assertion for CI; this sandbox cannot acquire the OS-user configuration lock. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 19:16:14 +08:00
esengine e8f79dfdbb ci: count renames and odd file names in the platform scope 2026-10-11 04:10:35 -07:00
esengine 276cdeb8ac ci: run the macOS and Windows legs on pull requests only when a platform path changed
Pull requests whose diff is provably platform-neutral run only the Linux legs;
the macOS and Windows legs keep their check names but execute on an ubuntu
runner with every step skipped. Pushes to main-v2/studio are unchanged.
2026-10-11 04:02:54 -07:00
xlyy1 90d6f3688b fix(planmode): classify browser_open as unsafe and suggest web_fetch 2026-10-11 19:00:57 +08:00
YHH 596d420244 Merge pull request #12618 from esengine/fix/edit-connection-protocol
feat(studio): change a connection's protocol when editing it
2026-10-11 03:43:56 -07:00
YHH 7b3260f74f Merge pull request #12628 from esengine/fix/plan-browser-open-and-stopped-worker
fix(delegation): a cancelled worker's transcript reaches the parent, but is never an answer
2026-10-11 03:29:40 -07:00
yhh d76b45a281 fix(studio): name the protocol select in the edit form 2026-10-11 03:28:33 -07:00
yhh 08653907e7 refactor(serve): extract the protocol change from editProvider 2026-10-11 03:27:24 -07:00
yhh a489d1e3aa fix(studio): follow the draft protocol for the thinking field and refuse a protocol change on a pinned endpoint 2026-10-11 03:27:24 -07:00
yhh 1851a1991c feat(studio): change a connection's protocol when editing it 2026-10-11 03:27:24 -07:00
XTLine f0371fbe70 fix(studio): preserve perf harness machine settings
Give the benchmark window one stable BenchPort owner so its language, zoom, theme and save instrumentation still reach usePaint. Exercise the real harness and paint hook without changing browser guard assertions. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:41 +08:00
XTLine 5bd8d9678a fix(studio): follow the active local settings workspace
Select the active local pane, else the first local pane, for Memory, model catalogue resolution and legacy-session recovery. Keep machinewide pages on their stable host owner. Retire cached workspace settings ports after successful close and restore the unrelated portFor comment. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:36 +08:00
XTLine ddefb6f48d fix(serve): validate local workspace settings owners
Allow host settings requests to pin an existing local runtime. Refuse stale or remote owners without falling back, while unpinned machine settings retain the first live local owner. Cover real workspace memory mutations, model routing and runtime replacement. Refs #12674.

Assisted-by: OpenAI Codex
2026-10-11 18:08:25 +08:00
YHH 7aca93426b Merge pull request #12634 from esengine/feat/user-message-attachments
feat(control): user messages carry their stored attachments as a field
2026-10-11 03:03:31 -07:00
YHH e8432591d3 Merge pull request #12631 from esengine/fix/e2ebench-passk-population
fix(bench): compute pass^k and pass@k only over tasks that ran every trial
2026-10-11 02:41:13 -07:00
YHH a796093857 Merge pull request #12629 from esengine/fix/e2ebench-reproduce-verdict
fix(bench): name what the reproduction verdict compares instead of claiming a version and a corpus
2026-10-11 02:39:00 -07:00
yhh 5878d58377 fix(studio): the notification setting says when it fires, that it is off by default, and who picks the sound
The copy claimed alerts appear while the window is in the background; the sender
fires whether or not the window is in front. It now says so, says the switch
ships off, and that the sound is the system's with no separate setting.
2026-10-11 02:33:22 -07:00
yhh 074337af99 test(update): assert the installer arguments reach ShellExecute 2026-10-11 02:29:27 -07:00
YHH 0f699f8e02 Merge pull request #12638 from esengine/fix/job-failure-typed-12480
feat(jobs): carry a failed background command's exit code and signal as typed fields
2026-10-11 02:22:07 -07:00
YHH 503b0429b1 Merge pull request #12633 from esengine/fix/shell-probe-memo
fix(sandbox): do not relaunch a bash probe that hung for the same file within a minute
2026-10-11 02:19:28 -07:00
YHH e9c487cd2f Merge pull request #12621 from esengine/fix/stdio-mcp-handshake
fix(plugin): send initialize to a fresh child when the discovery probe goes unanswered
2026-10-11 02:12:36 -07:00
yhh a516e8e709 fix(update): start Studio's Windows installer with --updated so an update keeps shortcuts and the taskbar pin
The update path started the NSIS installer with no arguments, so it ran
as a fresh install: the previous uninstaller deleted the shortcuts and
removed the AppUserModelID entries (the taskbar pin), and the installer
recreated the desktop shortcut. With --updated the installer takes
electron-builder's in-place update path and keeps them.
2026-10-11 02:12:22 -07:00
XTLine b8459b0eff fix(studio): keep appearance inventory and imports on this machine 2026-10-11 17:03:15 +08:00
XTLine 17bdaad0b0 fix(studio): keep window settings independent of active panes 2026-10-11 17:02:58 +08:00
XTLine d1d25e63e5 fix(studio): bind machine memory storage and usage to their owner 2026-10-11 17:02:40 +08:00
XTLine 3bf87b4d0a fix(serve): resolve machine settings through a live local owner 2026-10-11 17:02:24 +08:00
yhh 8e483dde72 feat(tools): bash_output without a job id lists the running background jobs
The orchestrating agent had no way to ask which background jobs are still
running and for how long, so it kept a count of its own and miscounted. The
listing comes from the job manager that owns the jobs (id, kind, label,
elapsed), through the existing read-only tool so no tool schema is added.
list_subagents rows now report when a child started and, while it runs, for
how long.
2026-10-11 01:59:57 -07:00
yhh eeec501119 fix(studio): a line typed mid-thought no longer splits the thought into two blocks
The queued line is drawn after the message the model is still writing, and
appending the next chunk only looked at the last item, so the chunk opened a
new message and one stretch of thinking showed as two blocks. Appending now
continues the open message past lines that are still waiting; once a line has
been delivered and the earlier message settled, the next chunk is a new one.
2026-10-11 01:59:16 -07:00
YHH 99651759da Merge pull request #12613 from esengine/feat/mcp-auth-required-identity
feat(plugin): a 401 Bearer challenge carries a typed authentication identity
2026-10-11 01:50:33 -07:00
XTLine bc0b454ceb fix(studio): separate extension warning categories 2026-10-11 16:37:23 +08:00
YHH 19cdcaab0a Merge pull request #12625 from esengine/fix/reasoning-only-turn
fix(agent): tell the model when a reasoning-only response was cut off at a limit
2026-10-11 01:24:26 -07:00
YHH 8f115d1702 Merge pull request #12614 from esengine/fix/receipt-write-survives-tool-after
fix(readiness): a write that reached disk stays a write when tool.after reports it failed
2026-10-11 01:24:17 -07:00
yhh ad8b9c71ff fix(agent): tell the model when a reasoning-only response was cut off at a limit 2026-10-11 01:11:34 -07:00
YHH 4f768a84e8 Merge pull request #12626 from esengine/fix/rewrite-box-width-12452
fix(studio): the rewrite box spans its row instead of a bare textarea's width
2026-10-11 01:09:34 -07:00
yhh 14a1ee8f7f feat(studio): show the workspace's changed files as a pill above the composer
The branch chip's small "N changes" moves to a centred pill right above the
input box reading "N files changed +x -y", from the same tree.changes the
workbench lists. Clicking it opens the workbench's change section. A kernel
setting [ui] show_changed_files (default on) switches it off, which restores
the chip's small count.
2026-10-11 00:53:14 -07:00
par73e ca788d0966 fix(studio): refresh shared session trees on kernel changes 2026-10-11 15:21:22 +08:00
yhh d934f1c9dd fix(responses): send the schema's required detail on input_image to the official OpenAI host 2026-10-11 00:05:18 -07:00
yhh ac59199914 fix(responses): keep only the refused-request shape line; annotate after the status is known 2026-10-11 00:04:04 -07:00
YHH b9840adf1c Merge pull request #12573 from esengine/fix/provider-delete-feedback
fix(providers): show a refused removal beside Delete and clear every role naming the removed service
2026-10-11 00:01:20 -07:00
yhh a90e3e2bcd fix(responses): send the schema's required detail on input_image and say what shape a refused request had 2026-10-11 00:00:17 -07:00
YHH 368df20ed2 Merge pull request #12568 from esengine/fix/edit-connection-model-read
fix(providers): offer no model-listing read for a protocol that declares none
2026-10-10 23:43:50 -07:00
yhh 0b2d5327f4 feat: show how long each turn ran, measured by the kernel
TurnDone carries the run time up to the final answer; history carries it on
the answer that closed a turn. The terminal UI and Studio draw it under the
answer, and the live status timer uses the same compact form.
2026-10-10 23:19:32 -07:00
YHH 5d3bce4810 Merge pull request #12582 from esengine/fix/readonly-agent-capability-search
fix(agent): let read-only agents search the capability catalog
2026-10-10 23:19:22 -07:00
yhh 5900acb119 fix(update-helper): accept the package the update path names, and guard it in the package
The Linux helper's accepted package name was a build-time variable that
linux-payload.js never set, so the helper in the .deb kept
reasonix-desktop and refused reasonix-studio as a different package.
It now takes the name from update.StudioLine, the one place the shell
asks dpkg by, so there is no build value to forget. A package command
prints it, studio.yml asserts the packaged helper accepts the .deb's own
package name, and a test ties electron-builder.yml's deb and rpm names
to it.
2026-10-10 22:03:58 -07:00
esengine 9d63534b05 docs(readme): size the sponsor logos alike and centre them in their cells 2026-10-10 21:55:03 -07:00
YHH aab1a087b9 Merge pull request #12639 from esengine/docs/sponsor-rightcode
docs(readme): thank Right Code as a sponsor, listed first
2026-10-10 21:50:19 -07:00
yhh b9b934e218 fix(delegation): a cancelled worker's transcript reaches the parent, but is never an answer
A read-only worker cut off by cancellation left a transcript the parent never
heard of: the aggregate said only context canceled. The reference now travels
with the cancellation (parallel_tasks, fleet, task) and read_subagent_result
renders what a cancelled child had said, called and read, labelled as not a
result and its tool output as untrusted. ReadFinalAnswer still refuses any
run that did not complete, so fleet adopt_ref cannot adopt one.
2026-10-10 21:44:25 -07:00
yhh dbffc3d740 fix(studio): a column's drag handle no longer covers the scrollbar beside it
The workbench handle lay full height over the transcript's scrollbar while
shut, and the open handles reached into the scrollbar on either side of a
seam, so a press on the thumb resized the column instead of scrolling. The
workspace handle now starts inside the seam, the docked transcript steps in
from the workbench seam, and the shut workbench handle is only as tall as its
grip, centred and set left of the scrollbar. The grip starts the drag itself
once the press has travelled.
2026-10-10 21:39:42 -07:00
yhh fb96492170 fix(control): resuming a Goal runs it, and a reply to a blocked Goal puts it back in pursuit
/goal resume only flipped the state and said nothing, so the Goal sat idle until
another message arrived. It now says the Goal is resumed and runs the next turn
of it. A Goal the model or evaluator reported blocked also resumes on the user's
own next message, as one gated by await_user already did; the continuation epoch
is read after that lift so the lifted turn is advanced by the Goal instead of
being discarded as stale.

Refs #12554
2026-10-10 21:38:13 -07:00
yhh f25b8f79a8 fix(serve): let a model switch go through while only background jobs remain
The switch refused with busy.switch_model_jobs because closing the outgoing
controller cancelled its jobs. The replacement now adopts the job manager and
the workspace write lease, and the outgoing controller is retired without
closing them; its cleanup runs once the jobs it left have finished.
2026-10-10 21:34:36 -07:00
yhh 5a5b981965 fix(control): a refused submission carries a notice code the queueing frontend can word
The admission notices for a session switch, a workspace switch and a draining
runtime told every frontend to resend, but Studio already queues the refused
line on the typed busy code, so following the advice sent it twice. The notice
now carries input_refused; Studio words it as queued, other frontends keep the
English text.

Refs #12554
2026-10-10 21:31:07 -07:00
yhh 14125a17cb fix(studio): keep a long runtime notice whole and its dismiss button on screen
The notes ceiling clipped the dormant-rules sentence and pushed the dismiss
button to a hidden third row. The button now shares the sentence's first row,
the ceiling has a floor outside short windows, and the queue yields the
difference. Also keeps the diagnostic for payload codes whose detail was not
consumed and pins malformed-payload cases.
2026-10-10 21:29:16 -07:00
yhh bb1d478f7f fix(pluginpkg): keep the decoder error in the chain while showing its text as a literal 2026-10-10 21:24:32 -07:00
yhh 60f37ee7c6 fix(pluginpkg): finish rendering package data inside host sentences as literals
Skill shadow and unselected warnings, the migrated and remediation
sentences (plugin name), the persist-status warning, manifest read
warnings and the strict-decode error now show package data through
ShownLocator or ShownIdentity.
2026-10-10 21:24:32 -07:00
yhh 5047e081bc fix(pluginpkg): show package data inside host sentences as literals
Shadow warnings, the incompatible-plugin warning and the skipped
marketplace warning now write the whole sentence first and render the
package-supplied path, error text or reason with ShownLocator, so an
unterminated escape in that data cannot swallow the rest of the sentence.
2026-10-10 21:24:32 -07:00
yhh 775443b26e fix(readiness): an explicit hand-back ends the turn instead of being continued
conclude_blocked and await_user tell the model the turn ends, but a code write
with no check after it still produced a verification gap, so the host ran two
more rounds and reported the turn as failed. A successful hand-back after the
latest write now yields the continuation; the ledger still owes the change and
the completion receipt still reports it as unverified.

Refs #12554
2026-10-10 21:17:50 -07:00
yhh cea97c9b9d fix(textutil): one core for the deleting policies; launch marker leads and matches what was deleted
Display, launch and stored policies share one scrub core, so the report
that a launch line lost characters is produced by the same pass that
deleted them (ZWJ, ZWNJ, bidi, escapes) and never by a second rule. The
marker leads the line so tail truncation in serve and the TUI cannot cut
it, and is worded through i18n. observe.Sanitize delegates to the stored
policy; the policy table is declared in doc.go.
2026-10-10 21:03:16 -07:00
yhh 5e547cf514 fix(installsource): one bounded read for every local manifest 2026-10-10 21:00:33 -07:00
YHH 9e320a452f Merge pull request #12581 from esengine/fix/build-notices-once
fix(boot): emit build-time notices once per lineage across rebuilds
2026-10-10 20:52:00 -07:00
yhh c0b770b7cf feat(control): user messages carry their stored attachments as a field
TurnStarted and Steer events, and the rebuilt history, name the images under
.reasonix/attachments that a message references, resolved by the same
detectRefs pass that decides what the model sees and reported under their
normalized path. The transcript previews from that field instead of matching a
spelling in the text; the model still reads the @path text.
2026-10-10 20:48:32 -07:00
yhh 323a2a95f5 fix(receipt): normalize the host-owned verdict like the paths and fix the Windows tests 2026-10-10 20:44:43 -07:00
yhh 9b7dfce095 fix(sandbox): hold the bash and PowerShell PATH lookups to the shell launch rule 2026-10-10 20:43:27 -07:00
yhh 09cc425fd8 docs(readme): use the sponsor's own wording for the Right Code thanks 2026-10-10 20:37:35 -07:00
yhh c54a107f77 fix(studio): word every placeholder notice through one shared resolver
The runtime bar resolved variables for only two notice codes, so
permission_rules_dormant and job_* showed {n}/{list}/{rule}/{name} verbatim
plus the raw JSON payload. NoticeCard and RuntimeBar now share noticeWording();
a test fails when a code with placeholders has no registered reader.
2026-10-10 20:34:02 -07:00
yhh 581645829f fix(installsource): bound a local .mcp.json and name the cause of a refused install 2026-10-10 20:33:24 -07:00
yhh 6445e0a5e7 docs(readme): thank Right Code as a sponsor, listed first 2026-10-10 20:30:41 -07:00
yhh f2ab4b5f36 feat(jobs): carry a failed background command's exit code and signal as typed fields
The bash job function wraps the process's own ending in jobs.ExitError,
the failed-job notice payload carries exitCode and signal, and the
terminal UI and Studio word the sentence from them. The raw error text
stays on the payload and is what an untyped failure still shows.
2026-10-10 20:30:30 -07:00
yhh 93bfa7dd61 docs(bench): describe pass^k over the tasks that ran every trial 2026-10-10 20:29:09 -07:00
yhh 51625a22e5 docs(trusted-execution): describe the reproduction verdict by what it compares 2026-10-10 20:28:40 -07:00
yhh 23bb1985a1 fix(textutil): one declared policy per surface kind for package-supplied text
Display (prose) now drops bidirectional and format characters exactly as
the prose policy does, where it used to keep bidi controls. The launch
line keeps deleting invisible characters, because redaction has to match
a key a zero-width character split, but it now says so with a marker
instead of reading as an unaltered line. One hostile fixture is asserted
across the locator, prose, display and launch policies.
2026-10-10 20:24:39 -07:00
yhh aa55b66a75 fix(sandbox): one rule for where a shell may be launched from 2026-10-10 20:21:53 -07:00
yhh 6bd9f77ab6 fix(sandbox): do not relaunch a bash probe that hung for the same file within a minute 2026-10-10 20:19:41 -07:00
yhh 94e65f522d fix(receipt): judge host-owned paths when the call ran and treat links of every kind as not owned 2026-10-10 20:16:36 -07:00
yhh 6dab6f3c92 fix(plugin): stop the silent child before starting its replacement and bound the restart by the caller 2026-10-10 20:11:48 -07:00
yhh e3c382e66c fix(studio): reopen the window at the rectangle it was closed at
The shell always opened at the default size, so a window the person had
sized, moved or maximised came back as if nothing had been chosen. The
normal bounds and the maximised flag are now recorded as the window settles
and as it closes, and the next launch reopens there. A rectangle no display
shows any more keeps its size and lets the platform pick the place.
2026-10-10 20:09:32 -07:00
yhh e04d26ac82 fix(bench): compute pass^k and pass@k only over tasks that ran every trial 2026-10-10 20:08:50 -07:00
yhh f8c9bf5547 fix(bench): name what the reproduction verdict compares instead of claiming a version and a corpus 2026-10-10 20:06:44 -07:00
yhh dd46917414 fix(studio): a column's drag handle no longer covers the scrollbar beside it
The workbench handle lay full height over the transcript's scrollbar while
shut, and the open handles reached five to six pixels into the scrollbar on
either side of a seam, so a press on the thumb resized the column instead of
scrolling. Open handles now start one pixel inside the seam, a shut handle is
only as tall as its grip, and the grip starts the drag itself once the press
has travelled.
2026-10-10 19:58:55 -07:00
YHH d086618850 Merge pull request #12565 from esengine/fix/permission-rules-conditional-tools
fix(permissions): treat conditionally registered tools as known to the rule vocabulary
2026-10-10 19:57:07 -07:00
yhh 9a14a65e53 fix(studio): the rewrite box spans its row instead of a bare textarea's width
The message column shrinks to fit its content and a textarea at 100% of it
starts at its default width, so the rewrite box stayed about 270px wide in a
760px transcript. While a message is being rewritten its column now takes the
whole row.
2026-10-10 19:57:05 -07:00
yhh 1138f32e76 fix(plugin): send initialize to a fresh child when the discovery probe goes unanswered 2026-10-10 19:48:57 -07:00
YHH 56ece8f9af Merge pull request #12558 from XTLine/fix/studio-response-call-integrity
fix(responses): reject incomplete Studio tool calls / 拒绝 Studio 不完整工具调用
2026-10-10 19:36:48 -07:00
yhh 46c0f64d87 feat(plugin): a 401 Bearer challenge carries a typed authentication identity
The transport now returns ErrAuthenticationRequired (via AuthRequiredError with
server, resource and scope) for a 401 that carries a Bearer challenge on a
server without explicit credentials. InstallResultForError maps that identity
to action_required/authenticate and no longer matches words in the message or
promotes a bare 401/403 status. Adds a regression test that refresh stays at the
token endpoint stored at sign-in. The guide stops promising a Studio sign-in
control that does not exist yet.
2026-10-10 19:30:18 -07:00
yhh 1c5e59a200 fix(readiness): a write that reached disk stays a write when tool.after reports it failed
The receipt took its success from the result after extensions rewrote it, so
a code write refused or errored by tool.after left no mutation in the ledger
and, as the only change of the turn, no verification was asked for. A named-path
write now keeps the success of the tool's own execution.

Refs #12528
2026-10-10 19:28:48 -07:00
YHH 6e645e2d88 Merge pull request #12612 from esengine/test/cancel-inbox-hold-dispatch
test(control): make the scoped-cancel inbox test deterministic
2026-10-10 18:55:46 -07:00
yhh 87ccb646c2 test(control): hold the post-cancel dispatch so the scoped-cancel assertion sees the queue 2026-10-10 18:40:45 -07:00
yhh 898a5b1626 test(studio): keep the global test limits the one place that sets them
The feedback timer spy counted testing-library's own waitFor deadline once
asyncUtilTimeout became 10s; it now ignores timers armed from inside the
library. The settings-layers case swallowed a lookup that never matched its
textbox name, which cost a full asyncUtilTimeout; it now asserts the real name.
vitest gets testTimeout/hookTimeout of 60s, and find/zoom-keys drop their
private copies of the same limits.
2026-10-10 17:19:17 -07:00
YHH 80c6866e8c Merge branch 'studio' into fix/windows-frontend-test-env 2026-10-10 15:56:58 -07:00
yhh e7dd9a1625 test(studio): give async queries room on a loaded machine
Lazy-loaded Markdown and chart modules take close to a second to load on a
busy Windows runner, which is the whole default testing-library timeout.
2026-10-10 10:17:43 -07:00
xxoingr 87dae2a950 fix(installsource): keep the cause of a refused .mcp.json under kind=auto
When a .mcp.json is refused while planning (over the server limit, an
invalid entry), kind=auto hid the reason. A fetched .mcp.json fell
through to the skill attempt, and with strict:false the JSON was planned
as a skill named "mcp". A GitHub repository whose root .mcp.json was
refused ended as a remote HTTP MCP plan at github.com (a repo named like
"mcp") or as ErrUnsupportedKind. A local .mcp.json file ended in "no
installable ... found", and a folder carrying a refused .mcp.json
dropped it without a word.

A source that is itself a .mcp.json, fetched or local, now answers with
its refusal under kind=auto as it already did under kind=mcp. A folder
or GitHub repository whose .mcp.json is refused keeps planning its other
installables; a folder warns with the path and the cause, and when
nothing else plans, the refusal is the error. A .mcp.json that is absent
or declares no server says nothing, as before.

readMCPJSON no longer returns a bare filesystem error: an absent file is
ErrManifestMissing and one that cannot be read (a directory, a denied
read) is ErrSourceUnreadable, so the caller can tell the cause.

Refs #12514
2026-10-11 00:31:41 +08:00
yhh 85a1cbdd8a feat(computer): refuse an ambiguous window target and let a call name its window
An application showing two or more independent top-level windows left the
front one to be guessed: a screenshot, typing, keys and coordinate steps went
to whichever was in front. computer_read and computer_act now take an optional
window id from the application list; without one, such a call is refused
before anything is sent with computer.ambiguous_window_target and the
candidates. A dialog another window owns is not a second window.
2026-10-10 08:35:45 -07:00
yhh 40826bfb13 perf(config): run the retired-key migrations over one read of each config
Refs #12533
2026-10-10 07:28:12 -07:00
yhh d4dd348b14 fix(agent): let read-only agents search the capability catalog
The read-only execution guard admitted only list and inspect, so
use_capability action=search fell through to the unknown-action block even
though the tool declares it and resolves it exactly like list. The tool's
own description tells every model to search before calling a tool
unavailable, so a read-only subagent was steered into the block.

usecap now exports the declared actions and the catalog-read subset; the
guard reads that set instead of keeping its own copy. The refusal for an
action outside the declared set names the action and the allowed ones.
2026-10-10 04:06:14 -07:00
yhh 333b435321 fix(boot): emit build-time notices once per lineage across rebuilds
Every boot.Rebuild re-derived each build-time notice (dormant permission
rules, held project MCP servers, default-model fallback, missing key) from
unchanged state and emitted it again. The runtime owner now carries a notice
ledger; each build runs through a window that drops notices the previous
successful build already showed, so only new or changed notices are emitted.
2026-10-10 03:39:38 -07:00
XTLine 65e391a401 fix(control): retain stream failure cause during cleanup 2026-10-10 17:00:43 +08:00
XTLine f9d401b6ea fix(responses): accept structurally closed no-argument calls 2026-10-10 17:00:34 +08:00
yhh aa38634842 fix(providers): show a refused removal beside the Delete button and clear every role naming the removed service
A removal refusal rendered in a banner at the top of the settings pane, out of
view once the pane scrolled, so Delete appeared to do nothing. The banner is
now an inline alert in the provider detail header. The list is re-read after a
failed removal too, because the kernel may have written the removal before the
failure. RemoveProvider now clears the guardian, recovery, vision, triage,
decision and advisor roles that named the removed service, walking the same
role table the other repairs use, and its doc comment states the behaviour the
code and tests already have.
2026-10-10 01:44:17 -07:00
yhh 12bf175c64 fix(permissions): treat conditionally registered tools as known to the rule vocabulary
A rule naming a tool that assembly registers only under some configuration or
in a sub-registry (system_one, advise, best_of_n, apply_isolated,
discard_isolated, submit_plan, conclude_no_changes, review_report,
complete_subtask) was refused on save and reported dormant at load, because the
tool is absent from the registry at that moment. Each owner now declares its
conditional tool with tool.RegisterConditional and the controller's vocabulary
reads the declarations; a boot test reconciles every tool the assembly can
register against the built-ins plus the declarations. The dormant-rule item
shape is also held by wire-parity.
2026-10-10 01:16:59 -07:00
yhh edccdc416e fix(providers): offer no model-listing read for a protocol that declares none
The edit form showed 'read available models from provider' for a decision
source, whose protocol has no model listing. The provider entry view now
carries canListModels, derived from the protocol table's Discovery
declaration, and the editor hides both listing controls from it.
2026-10-10 00:42:38 -07:00
Will 5559945e7f test(e2ebench): register corpus authoring policies
The authoring guards were three hand-written lists in corpus_test.go: which
corpora must carry a reference solution, which must have a grader that accepts
it, and which must let the agent decide when to stop. A seventh corpus could be
added and be checked by none of them, which is what happened to four of the
seven that exist.

Each corpus now declares, in one registry, how it stands against each rule:
enforced, or exempt with a reason. Discovery walks the corpus root, so an
unregistered corpus is a failure rather than an omission from a list. The three
guards read the registry instead of their own lists, and the stop-policy guard
covers every registered corpus rather than the three that happened to be listed.

An entry must name the corpus this root owns: its directory is compared with the
corpus's path under the root, not with its name, so a registry entry pointing at
some other directory that happens to share a name cannot stand in for the
committed one.

The pruning test the registry replaces was weakened rather than extended: a new
corpus used to be silently unchecked.
2026-10-10 15:35:26 +08:00
XTLine fe52643a86 test(agent): expand terminal lifecycle coverage 2026-10-10 15:04:17 +08:00
XTLine accb314eee test(agent): verify safe recovery and cancellation 2026-10-10 15:04:12 +08:00
XTLine a4fe926a4b test(agent): assert terminal execution and wire history 2026-10-10 15:04:03 +08:00
XTLine d76d47b899 test(agent): add a runnable terminal HTTP fixture 2026-10-10 15:03:49 +08:00
XTLine 9b716795cd test(responses): cover terminal integrity boundaries 2026-10-10 15:03:34 +08:00
XTLine ff1bada908 fix(responses): refuse terminal tool-call loss 2026-10-10 15:03:29 +08:00
XTLine f9a0a0926f fix(agent): preserve typed stream failure attribution 2026-10-10 15:03:20 +08:00
Will 51ab972870 feat(bench): account for what each context-retrieval run costs
The harness measures context cost and could not report its own. It handed the
agent event.Discard, so the usage every billed completion already reports went
nowhere, and a batch's spend could only be read from a vendor console: this run's
index batch cost 604,544 prompt tokens (527,232 cached) plus 12,600 completion
tokens over 24 runs, and nothing in the tree would have said so.

The bench now installs a sink and totals what the agent reports — the same record
the product's own round path, compaction, command routing and failure paths bill
to. One path, not a second count of the same fact.

Two things stay honest about what the number means. A dry run prints an empty
suffix rather than "prompt-tok=0", because an unmeasured cost must not read as a
measured one. And the count is called usage_reported_requests: a request that
failed before reporting usage leaves no event, so it is not a count of attempts.
2026-10-09 20:08:15 +08:00
Will 40e8f0b9a9 docs(context-retrieval): record the v2 Experiment I result in Status
The substrate the Status section asked for now has a result, and the result has a
direction, a size that is not yet quotable, and a reason.

The direction reproduces: six of six tasks, and the pooled paired batch searches
less and pages in less with the cue visible. A size is visible on the four tasks
whose absent-cue arm stayed out of a loop (44% less prompt cost), and it is
recorded as a post-hoc subset rather than an effect size: the two excluded tasks
are excluded because their arm looped here, and the qualification bar does not
screen for that. Saying 44% without that qualifier would mean picking the sample
after seeing the result, which is the mistake that invalidated the previous round.

The counts are stated separately because they differ: 36 runs executed, and the
token totals belong to the 24-run index batch that carried the usage line.
2026-10-09 20:01:23 +08:00
819 changed files with 28977 additions and 6572 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+20 -13
View File
@@ -26,7 +26,9 @@ jobs:
# merges. Gated jobs skip only on an explicit `false` output: wrapped in
# `always()`, a failed `changes` job (or a missing output) makes them run
# the full matrix instead of silently passing required checks as skipped.
# Pushes to main-v2 always run everything.
# Pushes to main-v2 always run everything. Pull requests run the macOS and
# Windows legs only when `platform` is not 'false'; the rule lives in
# scripts/ci-platform-scope.sh and fails toward running them.
changes:
runs-on: ubuntu-latest
outputs:
@@ -34,13 +36,14 @@ jobs:
desktop: ${{ steps.filter.outputs.desktop }}
sdk: ${{ steps.filter.outputs.sdk }}
scripts: ${{ steps.filter.outputs.scripts }}
platform: ${{ steps.filter.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- id: filter
run: |
code=true; desktop=true; sdk=true; scripts=true
code=true; desktop=true; sdk=true; scripts=true; platform=true
base=""
if [ "${{ github.event_name }}" = "pull_request" ]; then
base="${{ github.event.pull_request.base.sha }}"
@@ -48,7 +51,7 @@ jobs:
base="${{ github.event.before }}"
fi
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
files=$(git diff --name-only "$base" HEAD)
files=$(git diff --name-only --no-renames "$base" HEAD)
if [ -n "$files" ]; then
code=false; desktop=false; sdk=false; scripts=false
# Root-module CI: desktop/ is a separate module, so only the
@@ -63,9 +66,10 @@ jobs:
if echo "$files" | grep -qE '^(sdk/|internal/ext/extension/)'; then sdk=true; fi
# The release-note tests render every committed Studio note; npm/ holds the publish tests.
if echo "$files" | grep -qE '^(scripts/|npm/|release-notes/studio/|\.github/workflows/release-studio\.yml$)'; then scripts=true; fi
platform=$(git diff -z --name-only --no-renames "$base" HEAD | bash scripts/ci-platform-scope.sh)
fi
fi
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; } >> "$GITHUB_OUTPUT"
{ echo "code=$code"; echo "desktop=$desktop"; echo "sdk=$sdk"; echo "scripts=$scripts"; echo "platform=$platform"; } >> "$GITHUB_OUTPUT"
release-notes-scripts:
needs: changes
@@ -77,14 +81,14 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: true
- run: node --test scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
- run: node --test scripts/ci-platform-scope.test.mjs scripts/cli-archive-build.test.mjs scripts/release-credits.test.mjs scripts/update-contributors.test.mjs scripts/update-acknowledgments.test.mjs scripts/markdown-refs.test.mjs scripts/studio-release-notes.test.mjs scripts/publish-cli-pointer.test.mjs scripts/studio-release-flags.test.mjs scripts/studio-notes-publish.test.mjs scripts/brew-cask-guard.test.mjs scripts/feedback-sync.test.mjs npm/publish.test.mjs
# The ruleset requires the per-OS check names (test (ubuntu-latest) etc.).
# A matrix job skipped at job level reports no per-leg checks at all, so
@@ -93,6 +97,9 @@ jobs:
# detector reports the diff is unrelated, every step skips and each leg
# reports success in seconds. `always()` also keeps the legs alive when
# the changes job itself fails (fail-open: an empty output != 'false').
# A pull request with no platform-relevant change moves the macOS and
# Windows legs onto an ubuntu runner with every step skipped: the check
# names the ruleset requires still report, without a scarce runner.
test:
needs: changes
if: always()
@@ -100,9 +107,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.code != 'false' }}
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
steps:
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
@@ -302,9 +309,9 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
runs-on: ${{ github.event_name == 'pull_request' && needs.changes.outputs.platform == 'false' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.changes.outputs.sdk != 'false' }}
RUN_STEPS: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.sdk != 'false' && (matrix.os == 'ubuntu-latest' || needs.changes.outputs.platform != 'false')) }}
defaults:
run:
working-directory: sdk/go
@@ -409,7 +416,7 @@ jobs:
# compile it against a real macOS toolchain.
desktop-macos:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: macos-latest
defaults:
run:
@@ -437,7 +444,7 @@ jobs:
# (//go:build windows), so the ubuntu leg above never compiles them.
desktop-windows:
needs: changes
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
if: always() && (github.event_name != 'pull_request' || (needs.changes.outputs.desktop != 'false' && needs.changes.outputs.platform != 'false'))
runs-on: windows-latest
defaults:
run:
@@ -547,7 +554,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage-report
path: coverage.out
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Sync
+26 -26
View File
@@ -202,7 +202,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -364,7 +364,7 @@ jobs:
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
@@ -397,7 +397,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
with:
name: ${{ matrix.artifact }}
@@ -479,7 +479,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
@@ -534,7 +534,7 @@ jobs:
-ExpectedSubject $env:EXPECTED_SUBJECT
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
@@ -576,7 +576,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -588,7 +588,7 @@ jobs:
run: pnpm install --frozen-lockfile
# The signed payload is the whole bundle, so it is packaged as it came.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
@@ -614,7 +614,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: |
@@ -649,12 +649,12 @@ jobs:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -732,7 +732,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -795,7 +795,7 @@ jobs:
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
@@ -832,13 +832,13 @@ jobs:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
@@ -912,7 +912,7 @@ jobs:
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
@@ -971,28 +971,28 @@ jobs:
# Every package by its exact name, never a pattern: any job without
# secrets can upload an artifact, and a pattern would publish it. Windows
# comes from the installer signing job when it ran, else the build leg.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-amd64
path: staging/studio-build-darwin-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-arm64
path: staging/studio-build-darwin-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
@@ -1035,19 +1035,19 @@ jobs:
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: staging/cli-archives
@@ -1404,7 +1404,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
registry-url: https://registry.npmjs.org
@@ -1413,7 +1413,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.resolve.outputs.candidate != 'true'
with:
name: cli-archives
@@ -1467,7 +1467,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: cli-archives
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test sweeper
@@ -46,7 +46,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -99,7 +99,7 @@ jobs:
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-selftest-windows-${{ steps.version.outputs.version }}
path: dist/ReasonixStudio-windows-amd64-installer.exe
+65 -13
View File
@@ -33,7 +33,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -114,7 +114,7 @@ jobs:
run_install: false
- if: steps.spa.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -141,9 +141,17 @@ jobs:
node perf/branch-tooltip.mjs
node perf/selfcheck.mjs branch-tooltip
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to
# studio, or a failed `scope`, runs all three.
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: scope
if: always()
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
strategy:
fail-fast: false
matrix:
@@ -152,9 +160,11 @@ jobs:
run:
working-directory: desktop
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- if: env.RUN_STEPS == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
- if: env.RUN_STEPS == 'true'
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: desktop/go.mod
cache: true
@@ -167,6 +177,7 @@ jobs:
# carries build dependencies of its own.
# Same command as `make studio-test`; this job is already in desktop/.
- name: go test
if: env.RUN_STEPS == 'true'
run: go test ./...
# The host sits in the main module and nothing else builds it, so its
@@ -174,7 +185,7 @@ jobs:
# platform-neutral or the Electron bundle cannot ship it. Cross-compiling
# is host-independent, so once is enough.
- name: host stays pure Go and platform-neutral
if: runner.os == 'Linux'
if: env.RUN_STEPS == 'true' && runner.os == 'Linux'
working-directory: .
run: |
for os in windows linux darwin; do
@@ -183,12 +194,15 @@ jobs:
# The ARM64 leg queues on a scarcer runner pool, so a pull request runs it
# only when it touches what the leg verifies; a push to studio always does.
# The macOS and Windows package legs follow `platform`, the same rule as the
# desktop matrix above.
scope:
name: package matrix
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.pick.outputs.matrix }}
arm64: ${{ steps.pick.outputs.arm64 }}
platform: ${{ steps.pick.outputs.platform }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -200,9 +214,11 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
arm64=true
platform=true
if [ "$EVENT" = pull_request ]; then
arm64=false
if git diff --name-only "$BASE_SHA" HEAD |
platform=$(git diff -z --name-only --no-renames "$BASE_SHA" HEAD | bash scripts/ci-platform-scope.sh)
if git diff --name-only --no-renames "$BASE_SHA" HEAD |
grep -qE '^(desktop/electron/|desktop/cmd/studio-manifest/|desktop/go\.(mod|sum)$|cmd/reasonix-studio-host/|internal/platform/(update|appupdate)/|go\.(mod|sum)$|scripts/(windows-signing-lib|verify-windows-package|verify-windows-authenticode|install-smoke)\.ps1$|\.github/workflows/(studio|release-studio)\.yml$)'; then
arm64=true
fi
@@ -212,13 +228,18 @@ jobs:
{"os":"ubuntu-22.04","arch":"amd64","platform":"linux"},
{"os":"windows-latest","arch":"amd64","platform":"windows","ebarch":"x64"}
]'
if [ "$arm64" = true ]; then
if [ "$platform" = false ]; then
legs="$(jq -c 'map(select(.platform == "linux"))' <<<"$legs")"
elif [ "$arm64" = true ]; then
legs="$(jq -c '. + [{"os":"windows-11-arm","arch":"arm64","platform":"windows","ebarch":"arm64"}]' <<<"$legs")"
else
legs="$(jq -c . <<<"$legs")"
fi
echo "arm64=$arm64" >> "$GITHUB_OUTPUT"
echo "matrix={\"include\":$legs}" >> "$GITHUB_OUTPUT"
{
echo "arm64=$arm64"
echo "platform=$platform"
echo "matrix={\"include\":$legs}"
} >> "$GITHUB_OUTPUT"
# The packaging contract, on real artifacts rather than on the config that
# describes them. Unsigned on purpose: signing and notarization are a
@@ -250,7 +271,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -315,6 +336,27 @@ jobs:
test -n "$dir"
node desktop/electron/packaging/pe.js "$dir" "${{ matrix.arch }}"
# An update starts the installer with --updated, and that is what keeps the
# shortcuts (and the taskbar pin hanging off them) from being deleted and
# recreated. Installed twice over itself the way an update does it, the
# desktop shortcut has to still be the same file, pointing at a path with
# no version in it, or every update costs the user their pin.
- name: An update keeps the shortcuts and they name no version
if: matrix.platform == 'windows'
shell: pwsh
run: |
$installer = (Get-ChildItem dist -Filter 'ReasonixStudio-windows-*-installer.exe' | Select-Object -First 1).FullName
$dir = Join-Path $env:RUNNER_TEMP 'rx-update-guard'
Start-Process $installer -ArgumentList '/S', "/D=$dir" -Wait
$lnk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Reasonix Studio.lnk'
$target = (New-Object -ComObject WScript.Shell).CreateShortcut($lnk).TargetPath
if ($target -ne (Join-Path $dir 'Reasonix Studio.exe')) { throw "the desktop shortcut targets $target, not the stable executable" }
$mark = [datetime]'2020-01-01'
(Get-Item $lnk).LastWriteTime = $mark
Start-Process $installer -ArgumentList '/S', '--updated', "/D=$dir" -Wait
if ((Get-Item $lnk).LastWriteTime -ne $mark) { throw 'an update rewrote the desktop shortcut' }
Start-Process (Join-Path $dir 'Uninstall Reasonix Studio.exe') -ArgumentList '/S', '/currentuser' -Wait
# The release packages a signed copy of the bundle and then reads both
# packages back against it. The same recipe on an unsigned copy, so the
# packaging contract (the installer's one application archive, the pinned
@@ -370,7 +412,7 @@ jobs:
- name: Keep the arm64 installer for the x64 refusal check
if: matrix.platform == 'windows' && matrix.arch == 'arm64'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-arm64-installer-ci
path: dist/ReasonixStudio-windows-arm64-installer.exe
@@ -417,6 +459,16 @@ jobs:
echo "::error::the package is $name; dpkg holds that beside the installed reasonix-studio instead of upgrading it, and update.StudioLine asks for it by name"
exit 1
fi
# The helper in the package accepts one package name, compiled in. It has
# to be the one the package carries, or every in-app update is refused
# as a different package after the download and signature succeed.
dpkg-deb --fsys-tarfile "$deb" | tar -xO ./usr/lib/reasonix-studio/reasonix-studio-update-helper > "$RUNNER_TEMP/update-helper"
chmod +x "$RUNNER_TEMP/update-helper"
accepts=$("$RUNNER_TEMP/update-helper" package)
if [ "$accepts" != "$name" ]; then
echo "::error::the update helper in the package accepts $accepts but the package is $name"
exit 1
fi
dpkg-deb -c "$deb" > contents.txt
# The path is the polkit action's exec.path annotation, so it is the
# only one authorization is granted for; root because polkit will not
@@ -506,7 +558,7 @@ jobs:
if: needs.scope.outputs.arm64 == 'true'
runs-on: windows-latest
steps:
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-arm64-installer-ci
path: installer
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Update README contributor tables
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test chart generator
+1 -1
View File
@@ -59,7 +59,7 @@
## ❤️Sponsor
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">Thanks to Right Code for sponsoring this project! Right Code reliably provides routing services for models such as Claude Code, Codex, and Gemini, with both pay-as-you-go and monthly subscription billing options available. Invoices are available upon top-up, and enterprise and team users can receive dedicated one-on-one support.</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">Thanks to PackyCode for sponsoring this project! Access leading AI models through PackyCode with one API endpoint and one API key, with automatic failover and dedicated high-speed routes for Codex and Claude Code.</td></tr></table>
## What it is
+1 -1
View File
@@ -57,7 +57,7 @@
## ❤️赞助商
<table><tr><td width="180"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="150"></picture></a></td><td>感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
<table><tr><td width="180" align="center" valign="middle"><a href="https://www.rightapi.ai/register?aff=e083bf73" target="_blank" rel="sponsored noopener noreferrer"><img src="./.github/sponsor/rightcode.png" alt="Right Code" width="64"></a></td><td valign="middle">感谢 Right Code 赞助了本项目!Right Code 稳定提供 Claude Code、Codex、Gemini 等模型的中转服务,并可选按量、包月两种计费模式。充值即可开票,企业、团队用户一对一对接。</td></tr><tr><td width="180" align="center" valign="middle"><a href="https://www.packyapi.ai/register?aff=rIbQ" target="_blank" rel="sponsored noopener noreferrer"><picture><source media="(prefers-color-scheme: dark)" srcset="./.github/sponsor/packycode-dark.svg"><img src="./.github/sponsor/packycode-light.svg" alt="PackyCode" width="80"></picture></a></td><td valign="middle">感谢 PackyCode 赞助了本项目!PackyCode 是一家稳定、高效的 API 中转服务商,统一域名、统一密钥、智能容灾切换。</td></tr></table>
## 它是什么
+3 -2
View File
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/ext/pluginpkg/**
- sensitive: internal/base/netclient/**
- sensitive: internal/safety/redirectguard/**
- sensitive: internal/safety/endpointclient/**
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
What follows bounds Observable today. It is not a restatement of the invariant:
moving a number here must not move the promise.
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
`complete` only below 50k files. Over that, or past a walk error, it
- `scanWorkspace` walks the workspace and every granted extra root recursively,
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
establishes nothing and the mutation stays unproven.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
+1 -1
View File
@@ -420,7 +420,7 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
+21 -10
View File
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
**Fold index marginal utility: unresolved.** Both the positive and the negative
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
The clean paired batch found a consistent effect (six of six tasks searched less
and paged in less with the cue visible), and the same-batch dose-response did
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
cue visible the index addresses the target, so the same task is searched less and pages in
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
absent).
Existing evidence is not sufficient to tune the shipped 1% budget in either
direction. Reopening this wants a new corpus, not more samples: that substrate
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
together 80% of the absent-cue total), and the qualification above does not screen for
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
sample after seeing the result; a batch screened on stopping stability is what would make
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
prompt tokens over three escapes).
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
the token totals here cover the 24-run index batch — the paired batch ran before the
per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -89,6 +89,15 @@ type contextMetrics struct {
// resident prefix; this is what is paid dynamically instead.
RecallReturnedTokens int `json:"recall_returned_tokens"`
// Provider usage as reported on the stream, or empty for a dry run. The count
// is completions that reported usage, so it is not a count of attempts.
UsageReportedRequests int `json:"usage_reported_requests,omitempty"`
PromptTokens int `json:"prompt_tokens,omitempty"`
CompletionTokens int `json:"completion_tokens,omitempty"`
CacheHitTokens int `json:"cache_hit_tokens,omitempty"`
CacheMissTokens int `json:"cache_miss_tokens,omitempty"`
UsageEstimated bool `json:"usage_estimated,omitempty"`
Searches []searchAttempt `json:"searches,omitempty"`
// PostHitSearches are queries issued after the target had already been
// returned: not a query problem, a result-to-read problem.
+10 -6
View File
@@ -14,7 +14,6 @@ import (
"reasonix/internal/assembly/boot"
"reasonix/internal/contract/ablation"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
"reasonix/internal/contract/tool"
_ "reasonix/internal/model/openai"
@@ -132,10 +131,11 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
}
boot.ApplyUnifiedProviderToolSurface(reg, false, arm)
var usage usageSink
a := agent.New(p, reg, sess, agent.Options{
ContextWindow: fixtureWindow, CompactRatio: 0.5, RecentKeep: 2,
SessionPath: f.Path, KeepPolicy: agent.KeepErrors, Ablation: arm,
}, event.Discard)
}, &usage)
a.LoadProjectionSidecar(f.Path)
// From here the transcript lives only in memory. On disk it is the answer.
sealFixture(f.Path)
@@ -161,6 +161,10 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
appended := sess.Snapshot()[before:]
m := scoreRun(appended, inst, f.Target, armName, cueVisible)
m.trajectory = appended
if u, requests := usage.snapshot(); requests > 0 {
m.UsageReportedRequests, m.PromptTokens, m.CompletionTokens = requests, u.PromptTokens, u.CompletionTokens
m.CacheHitTokens, m.CacheMissTokens, m.UsageEstimated = u.CacheHitTokens, u.CacheMissTokens, u.Estimated
}
m.scoreAnswer(finalAnswer(appended), inst)
if runErr != nil {
m.FailureStage = "RunError"
@@ -249,9 +253,9 @@ func runBoundaries(root string) int {
} else {
noCueSide = append(noCueSide, m)
}
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d escape=%d\n",
fmt.Printf("%-24s %-14s cue=%-3v %-14s search=%d read=%d direct=%-5v recall-tok=%-5d%s escape=%d\n",
t.ID, name, side.has, m.FailureStage, m.SearchCalls, m.ReadCalls,
m.CueDirectRead, m.RecallReturnedTokens, m.EscapeCalls)
m.CueDirectRead, m.RecallReturnedTokens, promptTokenSuffix(m), m.EscapeCalls)
}
}
reportDeltas(cueSide, noCueSide)
@@ -340,9 +344,9 @@ func runExperiment(experiment, root string, dry bool, tasks []contextTask) int {
}
byArm[arm.name] = append(byArm[arm.name], m)
all = append(all, m)
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d %v\n",
fmt.Printf("%-24s %-14s %-18s search=%d hit=%d read=%d recall-tok=%d%s %v\n",
t.ID, arm.name, m.FailureStage, m.SearchCalls, m.TargetSearchHits, m.ReadCalls,
m.RecallReturnedTokens, m.UnexpectedWorkTools)
m.RecallReturnedTokens, promptTokenSuffix(m), m.UnexpectedWorkTools)
}
}
reportFunnels(byArm)
+59
View File
@@ -0,0 +1,59 @@
package main
import (
"fmt"
"sync"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// usageSink reads what a run cost from the events the agent already emits. Every
// billed completion reports event.Usage — the round path, compaction, command
// routing and failure diagnosis all do — so the bench reads the same record the
// product reads instead of wrapping a provider and counting a second time.
type usageSink struct {
mu sync.Mutex
requests int
usage provider.Usage
}
func (s *usageSink) Emit(e event.Event) {
if e.Kind != event.Usage || e.Usage == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
// One event is one completion that reported usage; RequestCount covers a
// completion that merged attempts. A request that failed before reporting
// leaves no event, so this is not a count of attempts.
if e.Usage.RequestCount > 0 {
s.requests += e.Usage.RequestCount
} else {
s.requests++
}
u := *e.Usage
s.usage.PromptTokens += u.PromptTokens
s.usage.CompletionTokens += u.CompletionTokens
s.usage.TotalTokens += u.TotalTokens
s.usage.CacheHitTokens += u.CacheHitTokens
s.usage.CacheMissTokens += u.CacheMissTokens
s.usage.ReasoningTokens += u.ReasoningTokens
s.usage.Estimated = s.usage.Estimated || u.Estimated
}
func (s *usageSink) snapshot() (provider.Usage, int) {
s.mu.Lock()
defer s.mu.Unlock()
return s.usage, s.requests
}
// promptTokenSuffix renders what a run cost only when something reported it. A
// dry run reports nothing, and a trailing "prompt-tok=0" would read as a
// measured zero rather than as no measurement.
func promptTokenSuffix(m contextMetrics) string {
if m.UsageReportedRequests == 0 {
return ""
}
return fmt.Sprintf(" prompt-tok=%d", m.PromptTokens)
}
@@ -0,0 +1,54 @@
package main
import (
"testing"
"reasonix/internal/contract/event"
"reasonix/internal/contract/provider"
)
// The cost a run reports must be what the agent reported, summed across every
// completion it billed — including one that merged attempts and one that could
// only estimate. Any other event kind is not a cost.
func TestUsageSinkAccumulatesWhatTheAgentReports(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{}) // a kind that is not Usage
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 100, CompletionTokens: 10, TotalTokens: 110, CacheHitTokens: 60, CacheMissTokens: 40,
}})
sink.Emit(event.Event{Kind: event.Usage, Usage: &provider.Usage{
PromptTokens: 7, CompletionTokens: 3, CacheHitTokens: 2, CacheMissTokens: 5, RequestCount: 2, Estimated: true,
}})
sink.Emit(event.Event{Kind: event.Usage}) // a Usage event with no payload is not a cost
usage, requests := sink.snapshot()
if requests != 3 {
t.Fatalf("requests = %d, want 3 (1 + the merged attempt's 2; the empty event adds none)", requests)
}
if usage.PromptTokens != 107 || usage.CompletionTokens != 13 || usage.TotalTokens != 110 {
t.Fatalf("accumulated usage = %+v", usage)
}
if usage.CacheHitTokens != 62 || usage.CacheMissTokens != 45 {
t.Fatalf("cache split = %+v", usage)
}
if !usage.Estimated {
t.Fatal("Estimated was dropped when one completion reported an estimate")
}
}
// A dry run must not claim a cost it never paid, in the JSON or in the line a
// person reads: an empty suffix, not "prompt-tok=0".
func TestUsageSinkStaysEmptyWithoutUsage(t *testing.T) {
var sink usageSink
sink.Emit(event.Event{})
usage, requests := sink.snapshot()
if requests != 0 || usage.PromptTokens != 0 || usage.CompletionTokens != 0 {
t.Fatalf("nothing reported usage, got %+v requests=%d", usage, requests)
}
if suffix := promptTokenSuffix(contextMetrics{}); suffix != "" {
t.Fatalf("a run with no reported usage still printed %q", suffix)
}
if suffix := promptTokenSuffix(contextMetrics{UsageReportedRequests: 1, PromptTokens: 12}); suffix != " prompt-tok=12" {
t.Fatalf("a reported cost printed %q", suffix)
}
}
+534
View File
@@ -0,0 +1,534 @@
package main
import (
"fmt"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"testing"
"github.com/BurntSushi/toml"
fileencoding "reasonix/internal/base/fileutil/encoding"
)
// benchmarksRoot holds every committed task corpus. Validation discovers the
// corpora under it, so a corpus that never reaches the registry fails the check
// instead of staying outside every guard silently.
const benchmarksRoot = "../../benchmarks"
// authoringRule names one of the authoring guards a corpus answers to.
type authoringRule string
const (
rulePristineSeed authoringRule = "pristine-seed"
ruleReference authoringRule = "reference-solution"
ruleStopPolicy authoringRule = "stop-policy"
)
// allRules is the validator's closed vocabulary: a registry key outside it is an
// unknown rule, not a silently ignored one.
var allRules = []authoringRule{rulePristineSeed, ruleReference, ruleStopPolicy}
// ruleMode records how a rule applies to one corpus. There is no zero value that
// means "not listed": omission is not a mode, so a forgotten corpus reads as an
// error rather than as an exemption.
type ruleMode string
const (
// ruleEnforce applies the whole rule.
ruleEnforce ruleMode = "enforce"
// ruleEnforceWhenPresent applies only the half that tests what a corpus
// ships: the artefact is optional, but a shipped one must still hold up.
// Reference rule only, and it needs the reason the corpus is allowed to
// omit solutions.
ruleEnforceWhenPresent ruleMode = "enforce-when-present"
// ruleExempt keeps the corpus outside the rule, for the recorded reason.
ruleExempt ruleMode = "exempt"
)
type ruleDecision struct {
Mode ruleMode
Reason string
}
type corpusPolicy struct {
Dir string
Rules map[authoringRule]ruleDecision
}
// corpusPolicies is the single registry the authoring guards read. Every
// committed corpus appears once, and every corpus/rule pair carries an explicit
// decision, including the pairs that are deliberately outside a rule.
func corpusPolicies() []corpusPolicy {
return []corpusPolicy{
{
Dir: corpusDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforceWhenPresent,
Reason: "the e2e suite does not commit reference solutions, so a task without one is skipped, not failed"},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: verificationStressDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: trainCorpusDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: memorybenchDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleExempt,
Reason: "memorybench ships task-local memory facts instead of solution/ fixtures; TestMemorybenchGradersAcceptTheReferenceAnswer supplies a task-derived witness for every task and checks every grader accepts it"},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: fanoutWidthDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: upstreamEdgeDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
{
Dir: projectCheckDir,
Rules: map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
},
},
}
}
// policiesRunning selects the corpora whose decision for a rule runs that rule,
// which is what the authoring tests iterate over.
func policiesRunning(policies []corpusPolicy, rule authoringRule) []corpusPolicy {
var out []corpusPolicy
for _, p := range policies {
switch p.Rules[rule].Mode {
case ruleEnforce, ruleEnforceWhenPresent:
out = append(out, p)
}
}
return out
}
// committedCorpora lists the directories under root that carry tasks/.
func committedCorpora(root string) ([]string, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if !e.IsDir() {
continue
}
if dirExists(filepath.Join(root, e.Name(), "tasks")) {
out = append(out, e.Name())
}
}
sort.Strings(out)
return out, nil
}
// validateCorpusPolicies checks a registry against the corpora committed under
// root and returns one actionable error per problem. Tests supply their own
// root, so the discovery rules can be exercised without committing fake
// corpora into benchmarks/.
func validateCorpusPolicies(root string, policies []corpusPolicy) []error {
var errs []error
committed, err := committedCorpora(root)
if err != nil {
return []error{fmt.Errorf("list corpora under %s: %w", root, err)}
}
registered := map[string]bool{}
for _, p := range policies {
name := filepath.Base(p.Dir)
if registered[name] {
errs = append(errs, fmt.Errorf("corpus %q is registered more than once", name))
continue
}
registered[name] = true
// Identity is the path under this root, not the directory's name: a
// registry entry pointing anywhere else would have the guards check a
// corpus that only shares a name with the one committed here.
want := filepath.Clean(filepath.Join(root, name))
if got := filepath.Clean(p.Dir); got != want {
errs = append(errs, fmt.Errorf("corpus %q is registered at %s, but this root's corpus is %s", name, got, want))
}
if !dirExists(filepath.Join(p.Dir, "tasks")) {
errs = append(errs, fmt.Errorf("corpus %q is registered but has no tasks/ directory at %s", name, p.Dir))
}
for rule := range p.Rules {
if !slices.Contains(allRules, rule) {
errs = append(errs, fmt.Errorf("corpus %q declares an unknown rule %q", name, rule))
}
}
for _, rule := range allRules {
decision, ok := p.Rules[rule]
if !ok {
errs = append(errs, fmt.Errorf("corpus %q has no decision for rule %q", name, rule))
continue
}
switch decision.Mode {
case ruleEnforce:
// The full rule applies; no reason is needed or read.
case ruleEnforceWhenPresent:
if rule != ruleReference {
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q, which only applies to %q",
name, decision.Mode, rule, ruleReference))
}
if strings.TrimSpace(decision.Reason) == "" {
errs = append(errs, fmt.Errorf("corpus %q uses mode %q for rule %q without the reason that allows it",
name, decision.Mode, rule))
}
case ruleExempt:
if strings.TrimSpace(decision.Reason) == "" {
errs = append(errs, fmt.Errorf("corpus %q is exempt from rule %q without a reason", name, rule))
}
default:
errs = append(errs, fmt.Errorf("corpus %q declares an unknown mode %q for rule %q", name, decision.Mode, rule))
}
}
}
for _, name := range committed {
if !registered[name] {
errs = append(errs, fmt.Errorf("corpus %q is committed under %s but is not registered, so no authoring guard covers it", name, root))
}
}
sort.Slice(errs, func(i, j int) bool { return errs[i].Error() < errs[j].Error() })
return errs
}
// taskBounds is a task's own declaration of its bounds. Pointers keep a missing
// key distinct from a declared zero.
type taskBounds struct {
MaxSteps *int `toml:"max_steps"`
TimeoutSec *int `toml:"timeout_sec"`
}
// stopPolicyViolations reports the tasks in one corpus that break the stop
// rule. It reads each task.toml: the rule is about what a task declares, and a
// default is not a declaration.
func stopPolicyViolations(dir string) ([]string, error) {
tasksDir := filepath.Join(dir, "tasks")
entries, err := os.ReadDir(tasksDir)
if err != nil {
return nil, err
}
var out []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
id := entry.Name()
data, err := fileencoding.ReadFileUTF8(filepath.Join(tasksDir, id, "task.toml"))
if err != nil {
return nil, fmt.Errorf("%s: %w", id, err)
}
var bounds taskBounds
if _, err := toml.Decode(string(data), &bounds); err != nil {
return nil, fmt.Errorf("%s: %w", id, err)
}
if bounds.MaxSteps != nil && *bounds.MaxSteps > 0 {
out = append(out, fmt.Sprintf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
id, *bounds.MaxSteps))
}
switch {
case bounds.TimeoutSec == nil:
out = append(out, fmt.Sprintf("%s does not declare timeout_sec; with no round cap the wall clock is the only bound left", id))
case *bounds.TimeoutSec <= 0:
out = append(out, fmt.Sprintf("%s declares timeout_sec = %d; with no round cap the wall clock is the only bound left", id, *bounds.TimeoutSec))
}
}
sort.Strings(out)
return out, nil
}
// The registry must describe every committed corpus, and every corpus/rule pair
// in it must be a decision the validator understands.
func TestCorpusPolicyRegistryCoversEveryCommittedCorpus(t *testing.T) {
for _, err := range validateCorpusPolicies(benchmarksRoot, corpusPolicies()) {
t.Error(err)
}
committed, err := committedCorpora(benchmarksRoot)
if err != nil {
t.Fatal(err)
}
if len(committed) == 0 {
t.Fatal("no committed task corpora found; the benchmark suites are missing")
}
if registered := len(corpusPolicies()); registered != len(committed) {
t.Errorf("registry holds %d corpora while %d are committed", registered, len(committed))
}
}
// The registry holds one decision per corpus and rule, so the enforcement sets
// cannot drift apart silently.
func TestCorpusPolicyEnforcementSets(t *testing.T) {
policies := corpusPolicies()
for _, c := range []struct {
rule authoringRule
want int
exempt []string
}{
{rulePristineSeed, 7, nil},
{ruleReference, 6, []string{memorybenchDir}},
{ruleStopPolicy, 7, nil},
} {
running := policiesRunning(policies, c.rule)
if len(running) != c.want {
t.Errorf("rule %q runs for %d corpora, want %d", c.rule, len(running), c.want)
}
for _, dir := range c.exempt {
for _, p := range running {
if p.Dir == dir {
t.Errorf("rule %q runs for %s, which the registry exempts", c.rule, filepath.Base(dir))
}
}
found := false
for _, p := range policies {
if p.Dir == dir && p.Rules[c.rule].Mode == ruleExempt {
found = true
}
}
if !found {
t.Errorf("%s is not recorded as exempt from rule %q", filepath.Base(dir), c.rule)
}
}
}
}
// tempCorpus lays a minimal corpus under root and returns its path, so negative
// cases run in a temporary universe instead of as fake corpora in benchmarks/.
func tempCorpus(t *testing.T, root, name, task, taskToml string) string {
t.Helper()
dir := filepath.Join(root, name)
taskDir := filepath.Join(dir, "tasks", task)
if err := os.MkdirAll(filepath.Join(taskDir, "workdir"), 0o755); err != nil {
t.Fatal(err)
}
if taskToml != "" {
if err := os.WriteFile(filepath.Join(taskDir, "task.toml"), []byte(taskToml), 0o644); err != nil {
t.Fatal(err)
}
}
return dir
}
func fullDecisions() map[authoringRule]ruleDecision {
return map[authoringRule]ruleDecision{
rulePristineSeed: {Mode: ruleEnforce},
ruleReference: {Mode: ruleEnforce},
ruleStopPolicy: {Mode: ruleEnforce},
}
}
// Every way a registry can fall out of step with the tree is reported against
// the corpus and rule it concerns.
func TestCorpusPolicyValidationCatchesGaps(t *testing.T) {
universe := func(t *testing.T, corpora ...string) (string, []corpusPolicy) {
t.Helper()
root := t.TempDir()
var policies []corpusPolicy
for _, name := range corpora {
dir := tempCorpus(t, root, name, "t-"+name, "")
policies = append(policies, corpusPolicy{Dir: dir, Rules: fullDecisions()})
}
return root, policies
}
cases := []struct {
name string
universe func(t *testing.T) (string, []corpusPolicy)
want string
}{
{
name: "unregistered corpus",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
tempCorpus(t, root, "forgotten", "t-forgotten", "")
return root, policies
},
want: "forgotten",
},
{
name: "registry entry whose tasks/ is gone",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
if err := os.RemoveAll(filepath.Join(root, "registered")); err != nil {
t.Fatal(err)
}
return root, policies
},
want: "no tasks/ directory",
},
{
name: "corpus with no decision for a rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
delete(policies[0].Rules, ruleStopPolicy)
return root, policies
},
want: string(ruleStopPolicy) + "\"",
},
{
name: "exemption without a reason",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleExempt}
return root, policies
},
want: "exempt from rule \"" + string(ruleReference) + "\" without a reason",
},
{
name: "enforce-when-present without a reason",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleReference] = ruleDecision{Mode: ruleEnforceWhenPresent}
return root, policies
},
want: "without the reason that allows it",
},
{
name: "unknown mode",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: "sometimes"}
return root, policies
},
want: "unknown mode",
},
{
name: "a registry entry that shadows the root's corpus by name",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
elsewhere := tempCorpus(t, t.TempDir(), "registered", "t-impostor", "")
policies[0].Dir = elsewhere
return root, policies
},
want: "but this root's corpus is",
},
{
name: "unknown rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules["vibes"] = ruleDecision{Mode: ruleEnforce}
return root, policies
},
want: "unknown rule",
},
{
name: "enforce-when-present outside the reference rule",
universe: func(t *testing.T) (string, []corpusPolicy) {
root, policies := universe(t, "registered")
policies[0].Rules[ruleStopPolicy] = ruleDecision{Mode: ruleEnforceWhenPresent, Reason: "because"}
return root, policies
},
want: "only applies to",
},
{
name: "a fully declared universe is accepted",
universe: func(t *testing.T) (string, []corpusPolicy) {
return universe(t, "one", "two")
},
want: "",
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
root, policies := c.universe(t)
errs := validateCorpusPolicies(root, policies)
if c.want == "" {
if len(errs) != 0 {
t.Fatalf("valid registry reported %v", errs)
}
return
}
if len(errs) == 0 {
t.Fatalf("no error reported, want one mentioning %q", c.want)
}
var jb strings.Builder
for _, err := range errs {
jb.WriteString(err.Error() + "\n")
}
joined := jb.String()
if !strings.Contains(joined, c.want) {
t.Fatalf("errors do not mention %q:\n%s", c.want, joined)
}
})
}
}
// The stop rule covers every registered corpus, so a cap anywhere is a failure
// rather than an unread declaration.
func TestStopPolicyAppliesToEveryRegisteredCorpus(t *testing.T) {
for _, p := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
if p.Rules[ruleStopPolicy].Mode != ruleEnforce {
t.Errorf("corpus %s uses mode %q for the stop rule; every registered corpus enforces both assertions",
filepath.Base(p.Dir), p.Rules[ruleStopPolicy].Mode)
}
}
root := t.TempDir()
for _, c := range []struct {
name string
corpus string
toml string
want []string
}{
{"a cap is rejected", "train", "max_steps = 8\ntimeout_sec = 300\n", []string{"max_steps = 8"}},
{"an omitted wall clock is rejected", "upstream-edge", "max_steps = 0\n", []string{"does not declare timeout_sec"}},
{"a zero wall clock is rejected", "fanout-width", "timeout_sec = 0\n", []string{"declares timeout_sec = 0"}},
{"a negative wall clock is rejected", "project-check", "timeout_sec = -1\n", []string{"declares timeout_sec = -1"}},
{"a declared bound is accepted", "memorybench", "timeout_sec = 300\n", nil},
{"no cap and no declaration of one is accepted", "verification-stress", "max_steps = 0\ntimeout_sec = 300\n", nil},
} {
t.Run(c.name, func(t *testing.T) {
dir := tempCorpus(t, root, c.corpus, "t-case", c.toml)
violations, err := stopPolicyViolations(dir)
if err != nil {
t.Fatal(err)
}
if len(c.want) == 0 {
if len(violations) != 0 {
t.Fatalf("accepted task reported %v", violations)
}
return
}
if len(violations) == 0 {
t.Fatalf("no violation reported, want one mentioning %q", c.want[0])
}
if !strings.Contains(violations[0], c.want[0]) {
t.Fatalf("violation %q does not mention %q", violations[0], c.want[0])
}
})
}
}
+15 -20
View File
@@ -189,7 +189,8 @@ func TestSolvableCorpusSeedsMustNotGradeClean(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, memorybenchDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
for _, policy := range policiesRunning(corpusPolicies(), rulePristineSeed) {
dir := policy.Dir
if !dirExists(dir) {
continue
}
@@ -242,7 +243,9 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Skipf("%s unavailable; the graders need a POSIX shell and python3", bin)
}
}
for _, dir := range []string{corpusDir, verificationStressDir, trainCorpusDir, fanoutWidthDir, upstreamEdgeDir, projectCheckDir} {
for _, policy := range policiesRunning(corpusPolicies(), ruleReference) {
dir := policy.Dir
decision := policy.Rules[ruleReference]
if !dirExists(dir) {
continue
}
@@ -259,10 +262,10 @@ func TestCorpusGradersPassTheReferenceSolution(t *testing.T) {
t.Parallel()
work := stageSeed(t, task.dir)
if !stageSolved(t, task.dir, work) {
if dir == trainCorpusDir || dir == verificationStressDir || dir == fanoutWidthDir || dir == upstreamEdgeDir || dir == projectCheckDir {
t.Fatal("no solution/: a corpus task must prove its grader can pass")
if decision.Mode == ruleEnforceWhenPresent {
t.Skip("no solution/: " + decision.Reason)
}
t.Skip("no solution/: the e2e suite does not commit reference solutions")
t.Fatal("no solution/: a corpus task must prove its grader can pass")
}
if err := gradeSeed(t, work); err != nil {
t.Fatalf("the reference solution does not grade clean, so no attempt can: %v", err)
@@ -341,25 +344,17 @@ func TestNoSolutionCorpusGradesTheInverseContract(t *testing.T) {
// A task that caps its own tool rounds measures the cap, not the agent: the
// product ships unbounded (agent.Run calls bounding the loop the host's call),
// so a capped corpus grades a configuration no default user runs. Only two
// committed tasks ever reached a cap, both no-solution ones, where the host's
// "summarise your progress" preempts the sentence the honesty score exists for.
// so committed benchmark tasks must not set one. With no cap the wall clock is
// the only bound left, so every task must declare a positive timeout_sec.
func TestCorpusLetsTheAgentDecideWhenToStop(t *testing.T) {
for _, dir := range []string{corpusDir, verificationStressDir, memorybenchDir} {
tasks, err := loadTasks(dir)
for _, policy := range policiesRunning(corpusPolicies(), ruleStopPolicy) {
dir := policy.Dir
violations, err := stopPolicyViolations(dir)
if err != nil {
t.Fatalf("load %s: %v", dir, err)
}
for _, task := range tasks {
if task.MaxSteps > 0 {
t.Errorf("%s declares max_steps = %d; let timeout_sec bound the resource and the agent bound the work",
task.ID, task.MaxSteps)
}
// Without a round cap the wall clock is the only backstop left, so
// every task needs one or a stuck run has nothing to end it.
if task.TimeoutSec <= 0 {
t.Errorf("%s has no timeout_sec; with no round cap it is the only bound left", task.ID)
}
for _, violation := range violations {
t.Errorf("%s: %s", filepath.Base(dir), violation)
}
}
}
+1 -1
View File
@@ -285,7 +285,7 @@ func main() {
timeoutSec := flag.Int("timeout", 1200, "agent timeout in seconds (diff mode)")
attempts := flag.Int("attempts", 1, "suite/diff modes: retry a task up to N times until an attempt passes (stochastic agent); enables Pass@≤N")
tapeRecord, tapeReplay := registerTapeFlags()
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N (all passed) beside mean pass@1")
trials := flag.Int("trials", 1, "suite mode: run every task N times and keep every outcome; reports pass^N and pass@N over tasks that ran all N trials, beside mean pass@1 over every graded trial")
flag.Parse()
*trajDir = absTrajectoryDir(*trajDir)
if *trials > 1 && *attempts > 1 {
+26 -16
View File
@@ -7,10 +7,11 @@ import (
"strings"
)
// reproductionSection says whether two reports are one capability version on
// one corpus, and whether their rates differ by more than chance would. A
// difference inside the interval is not a finding; one outside it is only a
// finding about versions if the version section says the two are one version.
// reproductionSection says whether two reports share a capability set and a
// task prompt set, and whether their rates differ by more than chance would.
// Those are the only identities compared: the capability hash and task id to
// prompt. A difference inside the interval is not a finding; one outside it is
// a finding about a version only if the same capability set says so.
func reproductionSection(pathA, pathB string) string {
a, errA := loadResults(pathA)
b, errB := loadResults(pathB)
@@ -19,8 +20,10 @@ func reproductionSection(pathA, pathB string) string {
}
var s strings.Builder
s.WriteString("\n### Reproduction\n\n")
fmt.Fprintf(&s, "- version: %s\n", versionVerdict(a, b))
fmt.Fprintf(&s, "- corpus: %s\n\n", corpusVerdict(a, b))
version, corpus := versionVerdict(a, b), corpusVerdict(a, b)
fmt.Fprintf(&s, "- capability set: %s\n", version.text)
fmt.Fprintf(&s, "- task prompts: %s\n", corpus.text)
s.WriteString("- not compared: commit, build, configuration, grader, task files other than the prompt\n\n")
s.WriteString("| Rate | A | B | B − A (95% CI) | |\n|---|---:|---:|---:|---|\n")
allWithin := true
for _, m := range []struct {
@@ -46,8 +49,8 @@ func reproductionSection(pathA, pathB string) string {
fmt.Fprintf(&s, "| %s | %d/%d | %d/%d | %+.1fpp (%+.1f, %+.1f) | %s |\n", m.name, ka, na, kb, nb,
100*(float64(kb)/float64(nb)-float64(ka)/float64(na)), 100*lo, 100*hi, verdict)
}
if allWithin && strings.HasPrefix(versionVerdict(a, b), "one version") && strings.HasPrefix(corpusVerdict(a, b), "same") {
s.WriteString("\nOne version on one corpus reproduces within the noise floor.\n")
if allWithin && version.same && corpus.same {
s.WriteString("\nThe same capability set and task prompts reproduce within the noise floor.\n")
}
return s.String()
}
@@ -56,7 +59,14 @@ func counted(results []result) []result {
return slices.DeleteFunc(slices.Clone(results), func(r result) bool { return r.Skipped || r.Attempt > 1 })
}
func versionVerdict(a, b []result) string {
// verdict is one identity comparison: same says the two sides matched on what
// was compared, text says what that was.
type verdict struct {
same bool
text string
}
func versionVerdict(a, b []result) verdict {
setOf := func(rs []result) []string {
var out []string
for _, r := range counted(rs) {
@@ -69,17 +79,17 @@ func versionVerdict(a, b []result) string {
sa, sb := setOf(a), setOf(b)
switch {
case len(sa) == 0 || len(sb) == 0:
return "unknown (a report records no capability set)"
return verdict{text: "unknown (a report records no capability set)"}
case len(sa) > 1 || len(sb) > 1:
return "mixed (a report spans more than one capability set)"
return verdict{text: "mixed (a report spans more than one capability set)"}
case sa[0] == sb[0]:
return "one version (" + short(sa[0]) + ")"
return verdict{same: true, text: "one set (" + short(sa[0]) + ")"}
default:
return fmt.Sprintf("two versions (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))
return verdict{text: fmt.Sprintf("two capability sets (%s vs %s): a difference is a comparison, not a reproduction", short(sa[0]), short(sb[0]))}
}
}
func corpusVerdict(a, b []result) string {
func corpusVerdict(a, b []result) verdict {
prompts := func(rs []result) map[string]string {
out := map[string]string{}
for _, r := range counted(rs) {
@@ -100,9 +110,9 @@ func corpusVerdict(a, b []result) string {
}
}
if differ == 0 {
return fmt.Sprintf("same %d tasks", len(pa))
return verdict{same: true, text: fmt.Sprintf("same %d task ids and prompts", len(pa))}
}
return fmt.Sprintf("%d tasks differ between the reports", differ)
return verdict{text: fmt.Sprintf("%d task ids or prompts differ between the reports", differ)}
}
func passRate(rs []result) (int, int) {
+7 -2
View File
@@ -46,17 +46,22 @@ func TestReproductionSection(t *testing.T) {
a := writeReport(t, dir, "a.json", "sha256:aaaaaaaaaaaaaaaa", 40, 50)
same := writeReport(t, dir, "b.json", "sha256:aaaaaaaaaaaaaaaa", 38, 50)
got := reproductionSection(a, same)
for _, want := range []string{"version: one version (aaaaaaaaaaaa)", "corpus: same 50 tasks", "within noise", "reproduces within the noise floor"} {
for _, want := range []string{"capability set: one set (aaaaaaaaaaaa)", "task prompts: same 50 task ids and prompts", "not compared: commit, build, configuration, grader", "within noise", "The same capability set and task prompts reproduce within the noise floor."} {
if !strings.Contains(got, want) {
t.Fatalf("same version report missing %q:\n%s", want, got)
}
}
for _, claim := range []string{"One version", "one corpus"} {
if strings.Contains(got, claim) {
t.Fatalf("the report claims %q, which no check compares:\n%s", claim, got)
}
}
worse := writeReport(t, dir, "c.json", "sha256:aaaaaaaaaaaaaaaa", 5, 50)
if got := reproductionSection(a, worse); !strings.Contains(got, "differs") || strings.Contains(got, "reproduces") {
t.Fatalf("a 70pp drop read as noise:\n%s", got)
}
other := writeReport(t, dir, "d.json", "sha256:bbbbbbbbbbbbbbbb", 40, 50)
if got := reproductionSection(a, other); !strings.Contains(got, "two versions") || strings.Contains(got, "reproduces") {
if got := reproductionSection(a, other); !strings.Contains(got, "two capability sets") || strings.Contains(got, "reproduces") {
t.Fatalf("two versions read as a reproduction:\n%s", got)
}
}
+1 -1
View File
@@ -51,7 +51,7 @@ func decideRollout(baseline, candidate []result) rolloutDecision {
case bv == cv:
hold("rollout.same_version")
}
if !strings.HasPrefix(corpusVerdict(baseline, candidate), "same") {
if !corpusVerdict(baseline, candidate).same {
hold("rollout.corpus_mismatch")
}
if !slices.ContainsFunc(counted(candidate), func(r result) bool { return r.NoSolution }) {
+26 -11
View File
@@ -50,42 +50,57 @@ func tallyTrials(results []result) (map[string]trialTally, int) {
return tally, k
}
// trialsLine reports reliability over repeated trials. pass^k counts a task
// only when every trial it ran passed; a task cut short by the budget is
// judged on the trials it has, and the line says how many were complete.
// Empty for a run without trials, so older reports read as before.
// trialsLine reports reliability over repeated trials. pass^k and pass@k are
// over the tasks that ran all k trials (k is the highest trial index that ran);
// a task cut short by the budget has no value for either and is listed apart.
// mean pass@1 is passes over every trial that ran to a grade. Empty for a run
// without trials, so older reports read as before.
func trialsLine(results []result) string {
tally, k := tallyTrials(results)
if k < 2 || len(tally) == 0 {
return ""
}
all, any, complete, runs, passes := 0, 0, 0, 0, 0
var flaky []string
var flaky, incomplete []string
for id, t := range tally {
runs += t.ran
passes += t.passed
if t.ran == k {
complete++
if t.passed > 0 && t.passed < t.ran {
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
}
if t.ran < k {
incomplete = append(incomplete, fmt.Sprintf("`%s` %d/%d", id, t.ran, k))
continue
}
complete++
if t.passed == t.ran {
all++
}
if t.passed > 0 {
any++
}
if t.passed > 0 && t.passed < t.ran {
flaky = append(flaky, fmt.Sprintf("`%s` %d/%d", id, t.passed, t.ran))
}
}
sort.Strings(flaky)
sort.Strings(incomplete)
passAll, passAny := fmt.Sprintf("n/a (no task ran %d trials)", k), "n/a"
if complete > 0 {
passAll, passAny = shareOf(all, complete), shareOf(any, complete)
}
line := fmt.Sprintf("**Reliability** (%d trials, %d/%d tasks complete): **pass^%d** %s · **pass@%d** %s · **mean pass@1** %s\n\n",
k, complete, len(tally), k, share(all, len(tally)), k, share(any, len(tally)), share(passes, runs))
k, complete, len(tally), k, passAll, k, passAny, share(passes, runs))
if len(incomplete) > 0 {
line += fmt.Sprintf("**Incomplete** (fewer than %d trials, not in pass^%d or pass@%d): %s\n\n", k, k, k, strings.Join(incomplete, " · "))
}
if len(flaky) > 0 {
line += "**Flaky** (passed some trials, not all): " + strings.Join(flaky, " · ") + "\n\n"
}
return line
}
func shareOf(n, d int) string {
return fmt.Sprintf("%s (%d/%d complete)", pct(n, d), n, d)
}
func share(n, d int) string {
return fmt.Sprintf("%s (%d/%d)", pct(n, d), n, d)
}
+23 -9
View File
@@ -21,8 +21,8 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
line := trialsLine(results)
for _, want := range []string{
"3 trials, 3/3 tasks complete",
"**pass^3** 33% (1/3)",
"**pass@3** 67% (2/3)",
"**pass^3** 33% (1/3 complete)",
"**pass@3** 67% (2/3 complete)",
"**mean pass@1** 56% (5/9)",
"`flaky` 2/3",
} {
@@ -35,15 +35,29 @@ func TestTrialsLineReportsReliabilityAcrossTrials(t *testing.T) {
}
}
// A trial the budget skipped is not a failure; the task is judged on the
// trials it ran, and the line says it was not complete.
func TestTrialsLineJudgesABudgetCutTaskOnWhatRan(t *testing.T) {
results := []result{
trialResult("a", 1, true), trialResult("a", 2, true),
trialResult("b", 1, true), {task: task{ID: "b"}, Trial: 2, Skipped: true},
// pass^k is the chance all k trials pass, which a task that ran fewer has no
// value for. A trial the budget skipped is not a failure, so such a task is
// left out of pass^k and pass@k and named, and its trials still count toward
// mean pass@1.
func TestTrialsLineKeepsATaskThatRanFewerTrialsOutOfPassK(t *testing.T) {
for _, bPassed := range []bool{true, false} {
results := []result{
trialResult("a", 1, true), trialResult("a", 2, true),
trialResult("b", 1, bPassed), {task: task{ID: "b"}, Trial: 2, Skipped: true},
}
line := trialsLine(results)
for _, want := range []string{"1/2 tasks complete", "**pass^2** 100% (1/1 complete)", "**pass@2** 100% (1/1 complete)", "**Incomplete** (fewer than 2 trials, not in pass^2 or pass@2): `b` 1/2"} {
if !strings.Contains(line, want) {
t.Fatalf("bPassed=%v: missing %q in:\n%s", bPassed, want, line)
}
}
}
}
func TestTrialsLineHasNoPassKWhenNoTaskRanEveryTrial(t *testing.T) {
results := []result{trialResult("a", 1, true), trialResult("a", 3, true), trialResult("b", 1, true), trialResult("b", 2, true)}
line := trialsLine(results)
if !strings.Contains(line, "1/2 tasks complete") || !strings.Contains(line, "**pass^2** 100% (2/2)") {
if !strings.Contains(line, "**pass^3** n/a (no task ran 3 trials)") || !strings.Contains(line, "**pass@3** n/a") {
t.Fatalf("line = %s", line)
}
}
+20 -4
View File
@@ -17,6 +17,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -67,6 +68,7 @@ type shellIdentity struct {
version string
exe string
pid int
logsDir string
}
func (s shellIdentity) stated() bool {
@@ -89,7 +91,7 @@ func studioInstall(shell shellIdentity) *update.Install {
// nil where the shell did not state enough to own one. Nil is the gate: the
// routes are not registered, so a host that cannot name the application it
// would replace has no way to be asked to replace it.
func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
func studioUpdateHost(shell shellIdentity, owner *shellOwner) appupdate.Capability {
if !shell.stated() {
return nil
}
@@ -97,7 +99,7 @@ func studioUpdateHost(shell shellIdentity, to io.Writer) appupdate.Capability {
// still work; the swap is what refuses, by name.
application, _ := update.ApplicationAt(shell.exe, shell.pid)
return appupdate.New(appupdate.Options{
Owner: &shellOwner{to: to},
Owner: owner,
Running: shell.version,
Line: update.StudioLine(),
Application: application,
@@ -142,13 +144,22 @@ func main() {
return
}
boot.SetComputerHelper(*computerHelper)
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID, logsDir: logsDirOf(*crashDir)}
releaseFatalLog := crashreport.InstallFatalLog(*crashDir, defaultVersion(shell.version))
code := run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell)
releaseFatalLog()
os.Exit(code)
}
// logsDirOf is the shell's logs directory: the crash directory it passed is
// the "crash" folder inside it.
func logsDirOf(crashDir string) string {
if strings.TrimSpace(crashDir) == "" {
return ""
}
return filepath.Dir(crashDir)
}
func defaultVersion(stated string) string {
if strings.TrimSpace(stated) != "" {
return stated
@@ -370,6 +381,10 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
<-ctx.Done()
share.Close()
}()
owner := &shellOwner{to: handshakeTo}
// A toast the user clicks is answered here, in the process that sent it, and
// the shell is the one that can bring its window forward.
notify.OnActivate(showOnToastClick(owner))
hub := serve.NewHub(serve.HubOptions{
Serve: hubCfg,
Surface: surface.Desktop,
@@ -384,8 +399,9 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, handshakeTo),
Update: studioUpdateHost(shell, owner),
Share: share,
LogsDir: shell.logsDir,
})
share.Attach(hub.Handler())
srv := serve.New(built.Controller, bc, hubCfg)
+10 -2
View File
@@ -9,13 +9,21 @@ import (
"sync"
)
// The acts a handover asks of the shell. Only two travel: releasing the
// application is this process's own hub, and the shell has nothing to do for it.
// The acts this process asks of the shell. Releasing the application is this
// process's own hub, so the shell has nothing to do for it; the other three are
// what only the shell can do.
const (
actRelaunch = "relaunch"
actQuit = "quit"
actShow = "show"
)
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
// only process that can bring its window forward, to do so.
func showOnToastClick(o *shellOwner) func() {
return func() { _ = o.ask(actShow) }
}
// shellOwner is the application this process was spawned by, reached over the
// pipe the handshake went out on -- the only channel pointing this way, since
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
+12 -2
View File
@@ -78,7 +78,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := studioUpdateHost(tc.shell, &out); got != nil {
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
}
})
@@ -89,7 +89,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
var out bytes.Buffer
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
if studioUpdateHost(shell, &out) == nil {
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
t.Fatal("a host that stated its application got no capability")
}
}
@@ -116,3 +116,13 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
}
}
// A clicked toast reaches the shell as one line on the pipe the handshake used,
// in the same shape the other acts take.
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
var out bytes.Buffer
showOnToastClick(&shellOwner{to: &out})()
if got := out.String(); got != "{\"act\":\"show\"}\n" {
t.Fatalf("pipe carried %q, want the show act", got)
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
| `electron/` | the shell: main process, preload bridge, packaging |
| `frontend-next/` | the SPA it serves |
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
| `internal/winuninstall/` | taking a previous Windows install over |
+1 -30
View File
@@ -63,11 +63,6 @@ func main() {
notesVersion = os.Args[5]
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
case "windows-payload":
if len(os.Args) != 4 {
usage()
}
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -88,34 +83,10 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
os.Exit(2)
}
func genWindowsPayloadManifest(dir, version string) error {
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("Windows payload %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Windows payload %s is not a regular file", name)
}
_, sum, err := hashFile(path)
if err != nil {
return err
}
hashes[name] = sum
}
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
}
// verifyFile checks <file> against <file>.minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
-26
View File
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
dir := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
if err != nil {
t.Fatal(err)
}
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
if err != nil {
t.Fatal(err)
}
for _, name := range update.WindowsPayloadFileNames() {
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
if hashes[name] != want {
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
}
}
}
-15
View File
@@ -1,15 +0,0 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// The current helper also requires staging-only extraction: NSIS never writes
// the live install, and the helper compare-and-publishes the claimed release
// unit after validating every staged member.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
-23
View File
@@ -1,23 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
if !strings.Contains(got, " /REASONIXSTAGE=1") {
t.Fatalf("auto-update must extract away from the live install, got %q", got)
}
}
@@ -1,10 +0,0 @@
//go:build !windows
package main
import "os"
// Windows verifies each executable with the OS Authenticode policy while the
// same read handle is held. Non-Windows builds keep the release-unit loader
// testable without importing platform-specific trust APIs.
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
@@ -1,97 +0,0 @@
//go:build windows
package main
import (
"fmt"
"io"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
// reads the payload through one handle that disallows concurrent writes,
// deletes, and renames. The bytes handed to the publisher therefore cannot
// change between signature verification and publication.
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
path16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, fmt.Errorf("open staged payload handle")
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
trustFile := &windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
File: handle,
}
trustData := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_NONE,
UnionChoice: windows.WTD_CHOICE_FILE,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_EXECUTE,
}
verifyErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(file)
}
+8 -5
View File
@@ -37,11 +37,11 @@ const (
exitPostVerify = 16
)
// packageName is the only dpkg package this helper will install, fixed at
// build time so no runtime input can redirect it. Studio builds the same
// source with its own name; each package installs its own binary, and Polkit
// authorizes that path alone.
var packageName = "reasonix-desktop"
// packageName is the only dpkg package this helper will install. It is Studio's
// line as the update path states it, compiled in, so no runtime input can
// redirect it and no build step has a value to forget; Polkit authorizes this
// helper's own path alone.
var packageName = update.StudioLine().Deb.Package
const (
dpkgDebPath = "/usr/bin/dpkg-deb"
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
switch args[0] {
case "install":
return runInstall(d, args[1:])
case "package":
fmt.Fprintln(os.Stdout, packageName)
return exitOK
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/update"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
// The helper installs one package and the shell asks dpkg about the same one:
// both must come from the update path's own line, or an in-app update is
// refused as a different package.
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
if packageName != update.StudioLine().Deb.Package {
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
}
var out bytes.Buffer
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
code := runWith(realDeps(), []string{"package"})
w.Close()
os.Stdout = old
_, _ = out.ReadFrom(r)
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
}
}
// electron-builder names the packages; the helper must agree with both.
func TestPackagedNamesMatchTheHelper(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
if err != nil {
t.Fatal(err)
}
names := map[string]string{}
section := ""
for line := range strings.SplitSeq(string(raw), "\n") {
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
}
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
names[section] = strings.TrimSpace(rest)
}
}
if names["deb"] != packageName || names["rpm"] != packageName {
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows && !linux
//go:build !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
os.Exit(2)
}
-581
View File
@@ -1,581 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/windows"
"reasonix/desktop/internal/winuninstall"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if !validSHA256(installerSHA256) {
logger.Print("missing or invalid --installer-sha256")
return 2
}
if toVersion == "" {
logger.Print("missing --to-version")
return 2
}
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
if installDir == "" {
logger.Print("missing --install-dir")
return 2
}
if parentPID != 0 {
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
claimed, releaseClaim, err := claimPendingFileUpdateFn(
toVersion,
createdAt,
transactionID,
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
}
}
return 1
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind update installer: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create update staging: %v", err)
return recoverUnstarted()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind update staging: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged installer: %v", err)
return recoverUnstarted()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract installer payload: %v", installerErr)
return recoverUnstarted()
}
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
if err != nil {
logger.Printf("publish staged release unit: %v", err)
if !publishStarted {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
// The release is already active and must not be rolled back for stale
// Add/Remove Programs metadata. A later update or full installer retries
// this idempotent reconciliation.
logger.Printf("reconcile Windows uninstall registration: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
if !validSHA256(expectedSHA256) {
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
}
sourceInfo, err := os.Lstat(sourcePath)
if err != nil {
return "", nil, err
}
if !sourceInfo.Mode().IsRegular() {
return "", nil, fmt.Errorf("installer is not a regular file")
}
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
if err != nil {
return "", nil, err
}
owner, err := os.Lstat(dir)
if err != nil {
return "", nil, err
}
cleanup := func() error {
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
}
fail := func(err error) (string, func() error, error) {
_ = cleanup()
return "", nil, err
}
source, err := os.Open(sourcePath)
if err != nil {
return fail(err)
}
defer source.Close()
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
if err != nil {
return fail(err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
syncErr := staged.Sync()
closeErr := staged.Close()
switch {
case copyErr != nil:
return fail(copyErr)
case syncErr != nil:
return fail(syncErr)
case closeErr != nil:
return fail(closeErr)
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
}
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
return fail(err)
}
return stagedPath, cleanup, nil
}
func verifyInstallerSHA256(path, expectedSHA256 string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("installer is not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("installer SHA-256 mismatch")
}
return nil
}
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
if !validSHA256(expectedSHA256) {
return nil, fmt.Errorf("installer SHA-256 is invalid")
}
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathUTF16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
fail := func(err error) (func(), error) {
_ = file.Close()
return nil, err
}
info, err := file.Stat()
if err != nil {
return fail(err)
}
if !info.Mode().IsRegular() {
return fail(fmt.Errorf("installer is not a regular file"))
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fail(err)
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fail(fmt.Errorf("installer SHA-256 mismatch"))
}
var once sync.Once
return func() {
once.Do(func() { _ = file.Close() })
}, nil
}
func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
if installDir == "" {
return nil
}
names := []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
"Reasonix.exe",
}
paths := make([]string, 0, len(names))
for _, name := range names {
paths = append(paths, filepath.Join(installDir, name))
}
return paths
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
// Keep the helper itself hidden, but let the NSIS update-progress window be
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
// performs every live replacement through the claimed transaction.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
return cmd.Run()
}
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := range 16 {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(cleanup)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
if os.IsNotExist(err) {
return nil
}
if os.IsExist(err) {
continue
}
return err
}
actual, err := os.Lstat(cleanup)
if err != nil {
return err
}
if !os.SameFile(owner, actual) {
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
restoreTo, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
}
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
}
return fmt.Errorf("Windows update staging changed before cleanup")
}
return os.RemoveAll(cleanup)
}
return fmt.Errorf("cannot allocate Windows update cleanup path")
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
@@ -1,563 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(source, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
staged, cleanup, err := stageVerifiedInstaller(source, expected)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cleanup() })
if staged == source {
t.Fatal("installer was not isolated from the mutable cache path")
}
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err != nil {
t.Fatalf("isolated installer changed with source: %v", err)
}
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
t.Fatalf("staged installer = %q, %v", got, err)
}
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err == nil {
t.Fatal("tampered staged installer passed final verification")
}
}
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
t.Fatal(err)
}
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
if cleanup != nil {
_ = cleanup()
}
t.Fatal("installer bytes different from desktop verification were accepted")
}
}
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
if code := run([]string{
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := tempdir.New(t)
seed := tempdir.New(t)
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
reconciledVersion := ""
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
if root != installDir {
t.Fatalf("reconcile root = %q, want %q", root, installDir)
}
reconciledVersion = targetVersion
return true, nil
}
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
if reconciledVersion != "v1.20.1" {
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error {
events = append(events, "wait")
return nil
}
runInstallerFn = func(string, string) error {
events = append(events, "installer")
return nil
}
startRelaunchFn = func(string, string) error {
events = append(events, "relaunch")
return nil
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
return tx, nil
}
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
events = append(events, "stage-installer:"+sha256)
return path + ".claimed", func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
events = append(events, "claim-installer")
return func() { events = append(events, "release-installer") }, nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
events[2] != "stage-installer:"+testInstallerSHA256 ||
events[3] != "claim-installer" || events[4] != "installer" ||
events[5] != "release-installer" || events[6] != "publish" ||
events[7] != "record-installed" || events[8] != "relaunch" ||
events[9] != "release" {
t.Fatalf("handoff events = %#v", events)
}
}
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
})
waitForProcessExitFn = func(uint32, time.Duration) error {
return errors.New("parent still running")
}
claimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
claimed = true
return &repair.UpdateTransaction{}, func() {}, nil
}
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if claimed || installerRan {
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if _, ok := repair.ReadUpdateApplyFailure(); ok {
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
}
}
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return nil }
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
}
failure, ok := repair.ReadUpdateApplyFailure()
if !ok || failure.UpdateTransactionID != transactionID {
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
}
}
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalLstatStaging := lstatUpdateStagingFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
lstatUpdateStagingFn = originalLstatStaging
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
return nil, errors.New("staging identity unavailable")
}
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", claimed.ToVersion,
"--created-at", claimed.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(claimed),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if installerRan {
t.Fatal("installer ran without a bound staging directory")
}
if !relaunched {
t.Fatal("helper did not relaunch after staging identity failure")
}
if releases == 0 {
t.Fatal("helper did not release the update claim")
}
}
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(path, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
release()
t.Fatal("installer remained writable while execution claim was held")
}
if err := os.Rename(path, path+".replaced"); err == nil {
release()
t.Fatal("installer remained renameable while execution claim was held")
}
release()
release()
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
t.Fatalf("installer remained frozen after claim release: %v", err)
}
}
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
release()
t.Fatal("installer with the wrong SHA-256 received an execution claim")
}
}
-238
View File
@@ -1,238 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
const maxWindowsPayloadMetadataSize = 64 << 10
var verifyWindowsPayloadManifestFn = update.Verify
type stagedFileUpdateMember struct {
targetPath string
content []byte
mode os.FileMode
}
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
// before any live release-unit member is moved. An existing Reasonix.exe is the
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
// installed package that did not have the alias remains unchanged.
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
}
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
}
info, err := os.Lstat(stagingDir)
if err != nil {
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
}
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
if err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
contents := make(map[string][]byte)
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
seenTargets := make(map[string]struct{}, len(claimed.Files))
for _, file := range claimed.Files {
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
targetKey := strings.ToLower(targetPath)
if targetPath == "" || targetPath == "." {
return nil, fmt.Errorf("load staged release unit: target path is invalid")
}
if _, ok := seenTargets[targetKey]; ok {
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
}
seenTargets[targetKey] = struct{}{}
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
continue
}
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
if err != nil {
return nil, err
}
sourcePath := filepath.Join(stagingDir, sourceName)
content, ok := contents[sourceName]
if !ok {
sourceInfo, statErr := os.Lstat(sourcePath)
if statErr != nil {
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
}
if !sourceInfo.Mode().IsRegular() {
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
}
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
if err != nil {
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
}
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
}
contents[sourceName] = content
}
members = append(members, stagedFileUpdateMember{
targetPath: targetPath,
content: content,
mode: 0o700,
})
}
// Publish the running desktop last. If an earlier member fails, the old
// desktop remains the executable entry point that can report/retry recovery.
sort.SliceStable(members, func(i, j int) bool {
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
return !iPrimary && jPrimary
})
return members, nil
}
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
if claimed == nil ||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
return fmt.Errorf("claimed release unit primary executable is invalid")
}
required := map[string]bool{
"reasonix-desktop.exe": false,
"reasonix-guard.exe": false,
"reasonix-launcher.exe": false,
"reasonix-update-helper.exe": false,
"reasonix-cli.exe": false,
"reasonix.exe": false,
}
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
for _, file := range claimed.Files {
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
if target == "" || target == "." ||
!strings.EqualFold(filepath.Dir(target), installDir) {
return fmt.Errorf("claimed release unit target is outside the installation directory")
}
name := strings.ToLower(filepath.Base(target))
seen, ok := required[name]
if !ok {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
if seen {
return fmt.Errorf("claimed release unit contains a duplicate target")
}
required[name] = true
}
for name, seen := range required {
if !seen {
return fmt.Errorf("claimed release unit omits %s", name)
}
}
if len(claimed.Files) != len(required) {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
return nil
}
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest: %w", err)
}
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
}
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
return nil, fmt.Errorf("verify signed release manifest: %w", err)
}
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
if err != nil {
return nil, err
}
return hashes, nil
}
func readWindowsPayloadMetadata(path string) ([]byte, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !pathInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
}
if !os.SameFile(pathInfo, info) {
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
}
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
if err != nil {
return nil, err
}
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
}
return data, nil
}
func windowsStagedSourceName(targetBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(targetBase)) {
case "reasonix-desktop.exe":
return "reasonix-desktop.exe", nil
case "reasonix-guard.exe":
return "reasonix-guard.exe", nil
case "reasonix-launcher.exe":
return "reasonix-launcher.exe", nil
case "reasonix-update-helper.exe":
return "reasonix-update-helper.exe", nil
case "reasonix-cli.exe":
return "reasonix-cli.exe", nil
case "reasonix.exe":
return "reasonix-launcher.exe", nil
default:
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
}
}
func publishLoadedFileUpdateReleaseUnit(
claimed *repair.UpdateTransaction,
members []stagedFileUpdateMember,
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
) ([]repair.FileUpdateInstallReceipt, error) {
if publish == nil || len(members) == 0 {
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
}
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
for _, member := range members {
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
if err != nil {
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
}
receipts = append(receipts, receipt)
}
return receipts, nil
}
@@ -1,377 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
t.Fatalf("last published member = %q, want desktop", got)
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, filepath.Base(target)+"="+string(content))
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
})
if err != nil {
t.Fatal(err)
}
if len(receipts) != len(members) {
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
}
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
}
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
t.Fatalf("publish order = %v", published)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
t.Fatal(err)
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
},
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("incomplete staged release unit was accepted")
}
}
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
for i, file := range complete.Files {
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "omits") {
t.Fatalf("missing target error = %v", err)
}
})
}
t.Run("extra", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "unexpected") {
t.Fatalf("extra target error = %v", err)
}
})
t.Run("duplicate", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate target error = %v", err)
}
})
t.Run("outside", func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "outside") {
t.Fatalf("outside target error = %v", err)
}
})
t.Run("primary", func(t *testing.T) {
claimed := *complete
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "primary") {
t.Fatalf("primary target error = %v", err)
}
})
}
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
content := "payload:" + name
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
t.Fatalf("missing portable alias was added to publish set: %+v", members)
}
}
}
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
claimed := &repair.UpdateTransaction{TargetKind: "file"}
members := []stagedFileUpdateMember{
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, target)
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
})
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
t.Fatalf("publish error = %v", err)
}
if len(published) != 1 {
t.Fatalf("published members = %v, want one attempted member", published)
}
if len(receipts) != 0 {
t.Fatalf("failed first publish returned receipts: %+v", receipts)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
return nil, errors.New("payload signature rejected")
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("unsigned staged payload must be rejected before publication")
}
}
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
var verified []string
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
verified = append(verified, filepath.Base(path))
return os.ReadFile(path)
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
t.Fatal(err)
}
if len(verified) != len(update.WindowsPayloadFileNames()) {
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
t.Fatalf("missing signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "verify signed release manifest") {
t.Fatalf("bad signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v3")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("release version drift error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
t.Fatalf("release member drift error = %v", err)
}
}
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
t.Helper()
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
installDir := tempdir.New(t)
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
for _, name := range update.WindowsPayloadFileNames() {
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
}
files = append(files, repair.UpdateTransactionFile{
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
MissingBefore: true,
})
return staging, &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: version,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: files,
}
}
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
t.Helper()
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
content, err := os.ReadFile(filepath.Join(staging, name))
if os.IsNotExist(err) {
content = []byte("missing:" + name)
} else if err != nil {
t.Fatal(err)
}
hashes[name] = update.WindowsPayloadSHA256(content)
}
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
t.Fatal(err)
}
}
func acceptWindowsPayloadManifestForTest(t *testing.T) {
t.Helper()
original := verifyWindowsPayloadManifestFn
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
if string(signature) != "test signature" {
return errors.New("unexpected test signature")
}
return nil
}
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
}
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
t.Helper()
writeWindowsPayloadManifestForTest(t, staging, version)
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
}
func useUnverifiedPayloadReaderForTest(t *testing.T) {
t.Helper()
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = os.ReadFile
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
}
@@ -1,118 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -1,131 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := tempdir.New(t)
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+1
View File
@@ -27,6 +27,7 @@ Json listApps() {
windows.push(Json::object()
.set("id", static_cast<long long>(reinterpret_cast<uintptr_t>(hwnd)))
.set("title", narrow(std::wstring(title, n > 0 ? n : 0)))
.set("owned", GetWindow(hwnd, GW_OWNER) != nullptr && !(GetWindowLongPtrW(hwnd, GWL_EXSTYLE) & WS_EX_APPWINDOW))
.set("bounds", rectJson(windowBounds(hwnd))));
}
apps.push(Json::object()
+3
View File
@@ -48,6 +48,9 @@ bool ownsPoint(DWORD pid, POINT at);
bool isFront(DWORD pid);
HWND heldOwner(HWND window, DWORD pid);
void front(DWORD pid);
// targetWindow aims the request being served at one of the application's
// windows, which appWindows then lists first; null leaves the front one.
void targetWindow(DWORD pid, HWND window);
RECT windowBounds(HWND hwnd);
Json rectJson(const RECT& r);
+7
View File
@@ -109,6 +109,13 @@ static Json handle(const std::string& method, const Json& params) {
if (!perApp.count(method)) throw Failure{"computer.bad_request", "unknown method " + method};
DWORD pid = pidParam(params);
requireOperable(pid);
struct Aim {
explicit Aim(DWORD pid, const Json& params) {
const Json* w = params.get("window");
if (w && w->isNumber() && w->number() > 0 && w->number() <= 9007199254740992.0) targetWindow(pid, reinterpret_cast<HWND>(static_cast<uintptr_t>(w->number())));
}
~Aim() { targetWindow(0, nullptr); }
} aim(pid, params);
if (method == "snapshot") return snapshot(pid);
if (method == "screenshot") return capture(pid);
if (method == "press") return press(pid, stringParam(params, "ref"));
+30 -6
View File
@@ -153,6 +153,30 @@ bool appListed(HWND top) {
return GetWindow(top, GW_OWNER) == nullptr && (ex & WS_EX_NOACTIVATE) == 0;
}
static HWND aimed = nullptr;
// ownsForeground is whether the foreground is the aimed window or a dialog it
// owns; with no window aimed any foreground of the process will do.
static bool ownsForeground(HWND window) {
if (!window) return true;
for (HWND w = GetForegroundWindow(); w; w = GetWindow(w, GW_OWNER)) {
if (w == window) return true;
}
return false;
}
void targetWindow(DWORD pid, HWND window) {
aimed = nullptr;
if (!window) return;
for (HWND w : appWindows(pid, true)) {
if (w == window) {
aimed = window;
return;
}
}
throw Failure{"computer.no_window", "that window is not one of the application's windows on screen"};
}
// appWindows lists a process's top-level windows front to back, dialogs it
// owns included, with the one in front of the person first.
std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
@@ -168,7 +192,7 @@ std::vector<HWND> appWindows(DWORD pid, bool includeMinimized) {
if (r.right - r.left > 1 && r.bottom - r.top > 1) w->out.push_back(hwnd);
return TRUE;
}, reinterpret_cast<LPARAM>(&w));
HWND fg = GetForegroundWindow();
HWND fg = aimed ? aimed : GetForegroundWindow();
for (size_t i = 1; i < w.out.size(); i++) {
if (w.out[i] == fg) {
w.out.erase(w.out.begin() + i);
@@ -223,7 +247,7 @@ HWND heldOwner(HWND window, DWORD pid) {
// foreground is. Windows grants the foreground only to a process that received
// the last input, so an input with no effect is injected first.
void front(DWORD pid) {
if (isFront(pid)) return;
if (isFront(pid) && ownsForeground(aimed)) return;
std::vector<HWND> wins = appWindows(pid, true);
if (wins.empty()) throw Failure{"computer.no_window", "the application has no window to bring to the front"};
HWND target = wins[0];
@@ -233,8 +257,8 @@ void front(DWORD pid) {
nudge.mi.dwFlags = MOUSEEVENTF_MOVE;
SendInput(1, &nudge, sizeof nudge);
SetForegroundWindow(target);
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
if (!isFront(pid)) {
for (int i = 0; i < 25 && !(isFront(pid) && ownsForeground(aimed)); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
if (!(isFront(pid) && ownsForeground(aimed))) {
DWORD fgThread = GetWindowThreadProcessId(GetForegroundWindow(), nullptr);
DWORD me = GetCurrentThreadId();
if (fgThread && fgThread != me && AttachThreadInput(me, fgThread, TRUE)) {
@@ -242,9 +266,9 @@ void front(DWORD pid) {
SetForegroundWindow(target);
AttachThreadInput(me, fgThread, FALSE);
}
for (int i = 0; i < 25 && !isFront(pid); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
for (int i = 0; i < 25 && !(isFront(pid) && ownsForeground(aimed)); i++) std::this_thread::sleep_for(std::chrono::milliseconds(20));
}
if (!isFront(pid)) {
if (!(isFront(pid) && ownsForeground(aimed))) {
throw Failure{"computer.needs_front",
"Windows kept this application from coming to the front; ask the person to click it once, then try again"};
}
+1
View File
@@ -16,6 +16,7 @@
"smoke": "electron test/smoke.js",
"browser-live": "electron test/browser_live.js",
"freeze-live": "electron test/freeze_live.js",
"windowstate-live": "electron test/windowstate_live.js",
"computer-live": "electron test/computer_live.js"
},
"devDependencies": {
+21
View File
@@ -0,0 +1,21 @@
"use strict";
// The acts the kernel asks of the application, because only this process can do
// them. relaunch and quit are a handover and arrive in that order, so arming
// the restart before ending is the sequence rather than a coincidence; show
// brings the window forward for a clicked toast.
//
// Nothing is answered. Each is performed by ending or by showing, so an
// acknowledgement would have to come from a process on its way out.
function actHandler({ app, showWindow }) {
return (act) => {
if (act === "relaunch") {
app.relaunch();
return;
}
if (act === "quit") app.quit();
if (act === "show") showWindow();
};
}
module.exports = { actHandler };
+32 -38
View File
@@ -27,9 +27,11 @@ const { BrowserViews } = require("./browserviews");
const { startBrowserRelay } = require("./browserrelay");
const { groundFor } = require("./ground");
const { loadPrefs, prefsFile, registerPrefs } = require("./prefs");
const { stateFile, openWindow } = require("./windowstate");
const { accelerationOff, shouldDisableGpu, graphicsHandler } = require("./graphics");
const { createPowerGuard, keepsAwake } = require("./powerguard");
const { openLogs, redactArgv, failStartup } = require("./shelllog");
const { actHandler } = require("./acts");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice } = require("./hostexit");
// A page in a minimized or fully covered window counts as hidden, and a hidden
@@ -64,6 +66,7 @@ const hostBinary = layout.hostBinary(where);
const computerHelper = layout.computerHelper(where);
const pageDir = layout.pageDir(where);
const acts = actHandler({ app, showWindow: () => showWindow() });
let kernel = null;
let win = null;
let origin = "";
@@ -127,7 +130,7 @@ async function launchKernel(args) {
app.quit();
}
},
onAct: handOver,
onAct: (act) => acts(act),
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
@@ -310,28 +313,34 @@ function createWindow() {
const mac = process.platform === "darwin";
const windows = process.platform === "win32";
const chrome = mac || windows;
return new BrowserWindow({
...fitted(),
...appIcon(),
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
// Shown once it has been measured against the screen it landed on; sizing a
// visible window makes the correction a flicker.
show: false,
backgroundColor: ground(),
frame: !windows,
titleBarStyle: mac ? "hiddenInset" : "default",
...(mac ? { trafficLightPosition: LIGHTS } : {}),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
// Stated rather than left to the defaults: this is the list a reviewer
// reads to know what the renderer may reach.
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webviewTag: false,
webSecurity: true,
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
return openWindow({
BrowserWindow,
screen,
file: stateFile(app.getPath("userData")),
fallback: fitted(),
min: MIN_SIZE,
options: {
...appIcon(),
minWidth: MIN_SIZE.width,
minHeight: MIN_SIZE.height,
// Shown once it has been measured against the screen it landed on; sizing a
// visible window makes the correction a flicker.
show: false,
backgroundColor: ground(),
frame: !windows,
titleBarStyle: mac ? "hiddenInset" : "default",
...(mac ? { trafficLightPosition: LIGHTS } : {}),
webPreferences: {
preload: path.join(__dirname, "preload.js"),
// Stated rather than left to the defaults: this is the list a reviewer
// reads to know what the renderer may reach.
nodeIntegration: false,
contextIsolation: true,
sandbox: true,
webviewTag: false,
webSecurity: true,
additionalArguments: [`--reasonix-titlebar=${chrome ? "1" : "0"}`],
},
},
});
}
@@ -517,21 +526,6 @@ async function hostLastWords() {
return logs.host.tail();
}
// The acts a handover asks of the application. The kernel decides when: it has
// downloaded and staged a replacement, and what is left is the part only this
// process can do. They arrive in this order, so arming the restart before
// ending is the sequence rather than a coincidence.
//
// Nothing is answered. Both are performed by ending, so an acknowledgement
// would have to come from a process on its way out.
function handOver(act) {
if (act === "relaunch") {
app.relaunch();
return;
}
if (act === "quit") app.quit();
}
// Before the handshake the only window is the starting one, and the launch
// decides for itself whether to go on.
app.on("window-all-closed", () => {
+13 -4
View File
@@ -1,5 +1,4 @@
"use strict";
const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
// How often the fold is re-read. It is a projection the kernel recomputes on
@@ -10,18 +9,25 @@ const FOLD_INTERVAL_MS = 5000;
// installTray brings the icon up and reports whether there is one. A shell with
// no icon must never background its window: hiding it would leave a running
// session with nothing left to bring it back.
function installTray(host, { onOpen, onQuit }) {
function installTray(host, { onOpen, onQuit }, electron = require("electron")) {
const { Menu, Tray, nativeImage, screen } = electron;
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
const paint = () => {
// The menu is rebuilt only when what it says changed. Linux hands the menu to
// the desktop's indicator over D-Bus, and replacing it every poll closes one
// the person has open or leaves the icon without one.
let painted = "";
const paint = (force = false) => {
// The sentence and the menu words come from the kernel with the numbers:
// the language is the desktop's setting, and one surface should not need a
// second catalogue to say what the first already said.
const line = state.fold?.line ?? "";
const words = state.fold?.labels ?? {};
const shown = JSON.stringify([line, words, !!state.prefs?.closeToTray]);
if (!force && shown === painted) return;
icon.setToolTip(line ? `Reasonix Studio — ${line}` : "Reasonix Studio");
icon.setContextMenu(
Menu.buildFromTemplate([
@@ -37,13 +43,16 @@ function installTray(host, { onOpen, onQuit }) {
// switch has one implementation whichever control asked.
const next = await host.setTrayPrefs(true, !state.prefs?.closeToTray);
if (next) state.prefs = next;
paint();
// The native checkbox has already flipped itself; redraw from state
// even when nothing changed, so a refused save puts it back.
paint(true);
},
},
{ type: "separator" },
{ label: words.quit ?? "Quit", click: onQuit },
]),
);
painted = shown;
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
+109
View File
@@ -0,0 +1,109 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// Where the window was when it last closed, so the next launch opens the same
// rectangle instead of the default. Only a rectangle some display can still
// show is reused: a monitor that has gone leaves the saved position pointing at
// nothing, and a frameless window has no title bar to drag back from there.
const SAVE_DELAY_MS = 400;
const GRAB = { width: 120, height: 40 };
const stateFile = (userData) => path.join(userData, "window-state.json");
const whole = (v) => Number.isFinite(v) && Number.isInteger(v);
function loadState(file) {
try {
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
if (!raw || typeof raw !== "object" || !whole(raw.width) || !whole(raw.height) || raw.width <= 0 || raw.height <= 0) return null;
const placed = whole(raw.x) && whole(raw.y);
return { ...(placed ? { x: raw.x, y: raw.y } : {}), width: raw.width, height: raw.height, maximized: raw.maximized === true };
} catch {
return null;
}
}
// Written beside and renamed over, so a crash mid-write leaves the last good copy.
function saveState(file, state) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state));
fs.renameSync(tmp, file);
}
// The strip a person grabs the window by must lie inside some display's work area.
function grabbable(saved, areas) {
if (saved.x === undefined) return null;
return (
areas.find((a) => {
const across = Math.min(saved.x + saved.width, a.x + a.width) - Math.max(saved.x, a.x);
return across >= Math.min(GRAB.width, saved.width) && saved.y >= a.y && saved.y + GRAB.height <= a.y + a.height;
}) ?? null
);
}
/** placement answers the rectangle to open at. The size is cut to the display
* that shows the saved position, and the position is moved in until the whole
* window fits; a rectangle nothing shows keeps only its size, cut to the
* primary display, and the platform picks the place. The layout's minimum
* comes last. */
function placement({ saved, areas, primary, fallback, min }) {
if (!saved) return { ...fallback, maximized: false };
const home = grabbable(saved, areas);
const area = home ?? primary;
const width = Math.max(min.width, Math.min(saved.width, area.width));
const height = Math.max(min.height, Math.min(saved.height, area.height));
if (!home) return { width, height, maximized: saved.maximized };
const within = (v, lo, span, size) => Math.max(lo, Math.min(v, lo + span - size));
return { x: within(saved.x, area.x, area.width, width), y: within(saved.y, area.y, area.height, height), width, height, maximized: saved.maximized };
}
// A minimised or full-screen window reports the screen's rectangle as its normal
// bounds on some platforms, so what it says then is not what to come back to.
const settled = (win) => !win.isMinimized() && !win.isFullScreen();
const capture = (win) => ({ ...win.getNormalBounds(), maximized: win.isMaximized() });
/** track writes the window's rectangle shortly after it settles and once more as
* it closes. A minimised or full-screen window keeps its normal bounds. */
function track(win, file, { delay = SAVE_DELAY_MS, timer = setTimeout, untimer = clearTimeout } = {}) {
let pending = null;
const write = () => {
pending = null;
if (win.isDestroyed() || !settled(win)) return;
try {
saveState(file, capture(win));
} catch {
// A rectangle that did not save is one launch at the default size, not a reason to fail the window.
}
};
const later = () => {
if (pending) untimer(pending);
pending = timer(write, delay);
};
for (const event of ["resize", "move", "maximize", "unmaximize"]) win.on(event, later);
win.on("close", () => {
if (pending) untimer(pending);
write();
});
}
/** openWindow builds the window at its remembered rectangle and keeps that
* record current. It is hidden until the caller shows it, so a maximise has
* nothing to flicker against. */
function openWindow({ BrowserWindow, screen, file, fallback, min, options }) {
const { maximized, ...rect } = placement({
saved: loadState(file),
areas: screen.getAllDisplays().map((d) => d.workArea),
primary: screen.getPrimaryDisplay().workArea,
fallback,
min,
});
const win = new BrowserWindow({ ...rect, ...options });
if (maximized) win.maximize();
track(win, file);
return win;
}
module.exports = { stateFile, loadState, saveState, placement, track, openWindow, GRAB };
+183
View File
@@ -1866,3 +1866,186 @@ test("packaging: a Windows installer named for x64 is renamed to amd64", async (
["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
);
});
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
const { EventEmitter } = require("node:events");
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
const MIN = { width: 760, height: 480 };
const FALLBACK = { width: 1440, height: 900 };
const open = (saved, areas = [AREA, SECOND]) => placement({ saved, areas, primary: areas[0], fallback: FALLBACK, min: MIN });
test("window state: only a whole rectangle is read back, and it survives a round trip", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
assert.equal(loadState(file), null);
fs.writeFileSync(file, "{not json");
assert.equal(loadState(file), null);
for (const bad of [{ width: 0, height: 500 }, { width: "900", height: 500 }, { width: 900.5, height: 500 }, [], null]) {
fs.writeFileSync(file, JSON.stringify(bad));
assert.equal(loadState(file), null, JSON.stringify(bad));
}
saveState(file, { x: 40, y: 60, width: 1000, height: 700, maximized: true });
assert.deepEqual(loadState(file), { x: 40, y: 60, width: 1000, height: 700, maximized: true });
fs.writeFileSync(file, JSON.stringify({ width: 1000, height: 700 }));
assert.deepEqual(loadState(file), { width: 1000, height: 700, maximized: false });
});
test("window state: a first launch opens at the default size and a saved rectangle is reopened as it was", () => {
assert.deepEqual(open(null), { ...FALLBACK, maximized: false });
const saved = { x: 300, y: 120, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(saved), saved);
assert.deepEqual(open({ ...saved, x: 2100, y: 100 }), { ...saved, x: 2100, y: 100 });
});
test("window state: a rectangle no display shows keeps its size and loses its place", () => {
const gone = { x: 2400, y: 100, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(gone, [AREA]), { width: 1100, height: 760, maximized: false });
const above = { x: 300, y: -500, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(above, [AREA]), { width: 1100, height: 760, maximized: false });
const huge = { x: 5000, y: 0, width: 4000, height: 3000, maximized: true };
assert.deepEqual(open(huge, [AREA]), { width: 1920, height: 1040, maximized: true });
});
test("window state: a saved size is raised to the layout's floor", () => {
const small = open({ x: 10, y: 10, width: 300, height: 200, maximized: false });
assert.deepEqual([small.width, small.height], [760, 480]);
});
function fakeWindow(normal) {
const win = new EventEmitter();
Object.assign(win, { maximized: false, destroyed: false, normal });
win.getNormalBounds = () => win.normal;
win.isMaximized = () => win.maximized;
win.isDestroyed = () => win.destroyed;
win.minimized = false;
win.fullscreen = false;
win.isMinimized = () => win.minimized;
win.isFullScreen = () => win.fullscreen;
return win;
}
test("window state: moves are written once they settle, a close is written at once, and a maximised window keeps its normal bounds", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
const timers = [];
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
track(win, file, { timer: (fn) => timers.push(fn) - 1, untimer: (id) => (timers[id] = () => {}) });
win.emit("resize");
win.emit("move");
win.emit("resize");
assert.equal(loadState(file), null);
timers.at(-1)();
assert.deepEqual(loadState(file), { x: 10, y: 20, width: 900, height: 600, maximized: false });
win.normal = { x: 50, y: 60, width: 1000, height: 650 };
win.maximized = true;
win.emit("close");
assert.deepEqual(loadState(file), { x: 50, y: 60, width: 1000, height: 650, maximized: true });
win.destroyed = true;
win.normal = { x: 1, y: 1, width: 5, height: 5 };
win.emit("move");
timers.at(-1)();
assert.equal(loadState(file).width, 1000);
});
test("window state: a minimised or full-screen close keeps the last settled record", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
track(win, file, { timer: () => 0, untimer: () => {} });
win.emit("close");
const kept = loadState(file);
win.normal = { x: 0, y: 0, width: 1920, height: 1040 };
win.minimized = true;
win.emit("close");
assert.deepEqual(loadState(file), kept);
win.minimized = false;
win.fullscreen = true;
win.emit("close");
assert.deepEqual(loadState(file), kept);
});
test("window state: a saved size is cut to the display it opens on and the window is moved in", () => {
const big = { x: 1700, y: 600, width: 1400, height: 900, maximized: false };
const out = open(big, [AREA]);
assert.deepEqual([out.width, out.height], [1400, 900].map((v, i) => Math.min(v, [1920, 1040][i])));
assert.ok(out.x + out.width <= 1920 && out.y + out.height <= 1040, JSON.stringify(out));
const small = { x: 100, y: 100, width: 1800, height: 1000, maximized: false };
const cut = open(small, [{ x: 0, y: 0, width: 1366, height: 728 }]);
assert.deepEqual(cut, { x: 0, y: 0, width: 1366, height: 728, maximized: false });
});
test("kernel acts: show brings the window forward, relaunch arms the restart, quit ends, anything else is ignored", () => {
const { actHandler } = require("../src/acts.js");
const calls = [];
const handle = actHandler({
app: { relaunch: () => calls.push("relaunch"), quit: () => calls.push("quit") },
showWindow: () => calls.push("show"),
});
for (const act of ["show", "relaunch", "quit", "unknown"]) handle(act);
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
});
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
const { installTray } = require("../src/tray.js");
const menus = [];
let tips = 0;
class FakeTray {
on() {}
setImage() {}
setToolTip() { tips++; }
setContextMenu(menu) { menus.push(menu); }
destroy() {}
}
const electron = {
Menu: { buildFromTemplate: (template) => ({ template }) },
Tray: FakeTray,
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
};
let fold = { line: "1 running", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => ({ closeToTray: true }) };
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
await tray.refresh();
await tray.refresh();
await tray.refresh();
assert.equal(menus.length, 1, "three identical reads rebuilt the menu");
assert.equal(tips, 1);
fold = { ...fold, line: "2 running" };
await tray.refresh();
assert.equal(menus.length, 2);
assert.equal(menus[1].template.find((item) => item.enabled === false).label, "2 running");
tray.close();
});
test("tray: a refused close-to-tray save redraws the checkbox, and a menu that failed to draw is tried again", async () => {
const { installTray } = require("../src/tray.js");
const menus = [];
let failNext = false;
class FakeTray {
on() {}
setImage() {}
setToolTip() {}
setContextMenu(menu) { if (failNext) { failNext = false; throw new Error("indicator busy"); } menus.push(menu); }
destroy() {}
}
const electron = {
Menu: { buildFromTemplate: (template) => ({ template }) },
Tray: FakeTray,
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
};
const fold = { line: "idle", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => null };
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
failNext = true;
await assert.rejects(tray.refresh());
await tray.refresh();
assert.equal(menus.length, 1, "a menu whose draw threw was never retried");
const toggle = menus[0].template.find((item) => item.type === "checkbox");
await toggle.click();
assert.equal(menus.length, 2, "a refused save left the flipped checkbox on screen");
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
tray.close();
});
+94
View File
@@ -0,0 +1,94 @@
"use strict";
// Opens, moves, maximises and closes real windows through the shell's own openWindow, then reopens them: the
// rectangle a window had when it closed is the one the next launch opens at. Needs no kernel and no network.
// Run with `pnpm windowstate-live`.
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { app, BrowserWindow, screen } = require("electron");
const { stateFile, openWindow } = require("../src/windowstate");
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const fails = [];
const check = (name, ok, got) => {
console.log((ok ? "ok " : "FAIL ") + name + (ok ? "" : " — " + JSON.stringify(got)));
if (!ok) fails.push(name);
};
const file = stateFile(fs.mkdtempSync(path.join(os.tmpdir(), "windowstate-live-")));
const launch = () =>
openWindow({
BrowserWindow,
screen,
file,
fallback: { width: 1000, height: 640 },
min: { width: 400, height: 300 },
options: { show: true, minWidth: 400, minHeight: 300 },
});
const closeOne = async (win) => {
win.close();
await wait(100);
};
// Closing the last window would otherwise end the run between launches.
app.on("window-all-closed", () => {});
app.whenReady().then(async () => {
const first = launch();
await wait(300);
check("a first launch opens at the default size", first.getBounds().width === 1000 && first.getBounds().height === 640, first.getBounds());
const area = screen.getPrimaryDisplay().workArea;
const rect = { x: area.x + 60, y: area.y + 50, width: 820, height: 560 };
first.setBounds(rect);
await wait(700);
await closeOne(first);
const kept = JSON.parse(fs.readFileSync(file, "utf8"));
check("closing records the rectangle", kept.width === rect.width && kept.height === rect.height && kept.x === rect.x && kept.y === rect.y && kept.maximized === false, kept);
const second = launch();
await wait(300);
const got = second.getBounds();
check("the next launch reopens at that rectangle", got.x === rect.x && got.y === rect.y && got.width === rect.width && got.height === rect.height, got);
second.maximize();
await wait(700);
await closeOne(second);
const maxed = JSON.parse(fs.readFileSync(file, "utf8"));
check("a maximised close keeps the rectangle it will restore to", maxed.maximized === true && maxed.width === rect.width && maxed.height === rect.height, maxed);
const third = launch();
await wait(500);
check("the next launch is maximised again", third.isMaximized() === true, third.getBounds());
third.unmaximize();
await wait(600);
const restored = third.getBounds();
check("and restores to the rectangle it had before", restored.width === rect.width && restored.height === rect.height, restored);
await closeOne(third);
const fourth = launch();
await wait(400);
fourth.maximize();
await wait(900);
fourth.minimize();
await wait(900);
await closeOne(fourth);
const minimised = JSON.parse(fs.readFileSync(file, "utf8"));
check("closing while minimised keeps the maximised record and its restore size", minimised.maximized === true && minimised.width === rect.width && minimised.height === rect.height, minimised);
fs.writeFileSync(file, JSON.stringify({ x: area.x + 40, y: area.y + 40, width: area.width + 800, height: area.height + 600, maximized: false }));
const big = launch();
await wait(400);
const o = big.getBounds();
check("a saved size larger than the display is cut to it and kept on screen", o.width <= area.width && o.height <= area.height && o.x >= area.x && o.y >= area.y && o.x + o.width <= area.x + area.width && o.y + o.height <= area.y + area.height, o);
await closeOne(big);
fs.writeFileSync(file, JSON.stringify({ x: area.x + area.width + 5000, y: 10, width: 700, height: 500, maximized: false }));
const lost = launch();
await wait(300);
const b = lost.getBounds();
const shown = screen.getAllDisplays().some((d) => b.x >= d.workArea.x && b.x < d.workArea.x + d.workArea.width && b.y >= d.workArea.y);
check("a rectangle on a display that is gone opens on one that is there", shown && b.width === 700, b);
await closeOne(lost);
console.log(fails.length ? `${fails.length} failed` : "all checks passed");
app.exit(fails.length ? 1 : 0);
});
+8 -8
View File
@@ -25,13 +25,13 @@
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/language": "^6.13.1",
"@codemirror/search": "^6.7.2",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@codemirror/view": "^6.43.14",
"@lezer/highlight": "^1.2.5",
"codemirror": "^6.0.2",
"katex": "^0.18.10",
"katex": "^0.19.0",
"react": "^19.3.0",
"react-dom": "^19.3.0",
"react-markdown": "^10.1.0",
@@ -44,16 +44,16 @@
"remark-math": "^6.0.0"
},
"devDependencies": {
"@babel/parser": "^8.0.6",
"@babel/parser": "^8.0.7",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.1.1",
"playwright": "^1.63.0",
"@vitejs/plugin-react": "^6.1.2",
"jsdom": "^30.1.2",
"playwright": "^1.64.0",
"typescript": "^7.0.2",
"vite": "^8.3.2",
"vite": "^8.3.3",
"vitest": "^5.0.3"
}
}
+7
View File
@@ -68,6 +68,13 @@ node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
node perf/navhint.mjs # Icon-rail hover hints: drawn, inside the window, clipped by no ancestor, painted over by nothing (light/dark × on/collapsed)
node perf/runningtool.mjs # 还没输出的运行中调用:行能展开、写明仍在运行与已运行多久、写出命令自带的超时
node perf/gutterhit.mjs # 分栏拖拽手柄不压在相邻滚动条上:滚动条拇指处按下命中的不是手柄,握把仍可拖开收起的栏
node perf/dock.mjs # 停靠的浏览器列从 pane 顶部起、与导航行并排,取消停靠后行归还(浅/深 × 宽/窄 × 首开/重载)
node perf/signals.mjs # 运行分析的信号轨说明:它是什么、为什么标记不能拖(浅/深 × 宽/手机)
node perf/narrow.mjs # 窄窗口 + 1.8 倍缩放:标题栏与输入区的行互不重叠、不越出窗口(浅/深 × 390/320 × 1.8/1.3,另加宽窗口对照)
node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏 开/仅收起时/关 × 浅/深);同一模型连续回复只在首条带头
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
```
`lang.mjs` 从仓库外跑时用 `PERF_SRC` 指向 `src/`(它要读固件源码来决定
+26 -1
View File
@@ -165,7 +165,7 @@ const edges = () => col.evaluate(() => {
};
});
const setMeasure = (full) => col.evaluate((f) => {
if (f) document.documentElement.dataset.measure = "full";
if (f) document.documentElement.dataset.measure = typeof f === "string" ? f : "full";
else delete document.documentElement.dataset.measure;
}, full);
const composeAt = {};
@@ -197,6 +197,31 @@ check("全宽:转录比输入框宽", full.flow.r - full.flow.l > full.compose
check("全宽:卡片跟着转录到边", full.boxed.every((x) => Math.abs(x.l - full.flow.l) <= 1 && Math.abs(x.r - full.flow.r) <= 1),
`转录 ${full.flow.l}–${full.flow.r},卡片 ${JSON.stringify(full.boxed)}`);
await col.setViewportSize({ width: 1440, height: 900 });
await setMeasure("full-all");
await col.waitForTimeout(300);
const fullAll = await edges();
for (const width of [1000, 720]) {
await col.setViewportSize({ width, height: 900 });
await setMeasure("full-all");
await col.waitForTimeout(300);
const n = await edges();
const inside = await col.evaluate(() => {
const b = document.querySelector(".compose").getBoundingClientRect();
const pane = document.querySelector(".compose").parentElement.getBoundingClientRect();
return { left: Math.round(b.left - pane.left), right: Math.round(pane.right - b.right), w: innerWidth };
});
check(`${width}px 连输入框一起铺满:输入框不超出所在栏`, inside.left >= 0 && inside.right >= 0, `左 ${inside.left},右 ${inside.right}`);
check(`${width}px 连输入框一起铺满:输入框与转录左右对齐(容差一个槽)`, Math.abs(n.compose.l - n.flow.l) <= 40 && Math.abs(n.compose.r - n.flow.r) <= 40,
`转录 ${n.flow.l}–${n.flow.r},输入框 ${n.compose.l}–${n.compose.r}`);
}
await col.setViewportSize({ width: 1440, height: 900 });
await setMeasure(false);
check("连输入框一起铺满:输入框与转录同宽", Math.abs(fullAll.compose.l - fullAll.flow.l) <= 40 && Math.abs(fullAll.compose.r - fullAll.flow.r) <= 40,
`转录 ${fullAll.flow.l}–${fullAll.flow.r},输入框 ${fullAll.compose.l}–${fullAll.compose.r}`);
check("连输入框一起铺满:输入框比适宜阅读宽", fullAll.compose.r - fullAll.compose.l > composeAt[1440].r - composeAt[1440].l + 100,
`适宜阅读 ${composeAt[1440].r - composeAt[1440].l}px,铺满 ${fullAll.compose.r - fullAll.compose.l}px`);
await browser.close();
if (fails.length) {
console.error(`\n${fails.length} 项不合格:\n ` + fails.join("\n "));
+92
View File
@@ -0,0 +1,92 @@
// A docked browser column starts at the top of the pane, beside the navigation row, and gives the rows back when it
// is undocked. Where things start is a layout question, so it is asked of a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.DOCK_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const geometry = (page) =>
page.evaluate(() => {
const box = (sel) => {
const el = document.querySelector(sel);
const r = el?.getBoundingClientRect();
return r && r.width > 0 ? { left: r.left, right: r.right, top: r.top, bottom: r.bottom } : null;
};
return { pane: box(".pane"), tabs: box(".pane > .tabs"), side: box('.pane > .scroll[data-pane="browser"]'), body: box(".pane > .pbody") };
});
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [label, width] of [["wide", 1440], ["narrow", 560]]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
const tag = `${scheme}/${label}`;
let docked = null;
for (const round of ["first open", "after a reload"]) {
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.waitForTimeout(700);
const open = await geometry(page);
docked = open;
check(`${tag} ${round}: the browser column is drawn`, !!open.side);
if (open.side) {
if (label === "wide") {
check(`${tag} ${round}: it starts at the top of the pane, beside the navigation row`, Math.abs(open.side.top - open.pane.top) <= 1 && Math.abs(open.tabs.top - open.pane.top) <= 1, `pane ${open.pane.top} tabs ${open.tabs.top} column ${open.side.top}`);
check(`${tag} ${round}: it runs to the bottom of the pane`, Math.abs(open.side.bottom - open.pane.bottom) <= 1, `${open.side.bottom} against ${open.pane.bottom}`);
check(`${tag} ${round}: the navigation row stays left of it`, open.tabs.right <= open.side.left + 1, `${open.tabs.right} / ${open.side.left}`);
} else {
check(`${tag} ${round}: the navigation row stays above the page`, !!open.tabs && open.side.top >= open.tabs.bottom - 1, JSON.stringify([open.tabs, open.side]));
}
}
if (SHOTS && round === "first open") await page.screenshot({ path: `${SHOTS}/${process.env.DOCK_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
}
await page.locator('[data-action="browser.open"]').first().click();
await page.waitForTimeout(700);
const shut = await geometry(page);
check(`${tag} undocked: the column is gone and the navigation row is as wide as the pane's body again`, (!shut.side || shut.side.left >= shut.pane.right - 1) && !!shut.tabs && shut.tabs.right - shut.tabs.left >= shut.pane.right - shut.pane.left - 24, JSON.stringify([shut.side, shut.tabs, shut.pane]));
await page.close();
}
}
// Every room between phone and wide, not two samples: the left column shrinks with the window, and the tabs it holds
// must still fit and stay clear of the browser column.
{
const page = await browser.newPage({ viewport: { width: 1440, height: 800 } });
const bad = [];
for (let width = 560; width <= 1440; width += 20) {
await page.setViewportSize({ width, height: 800 });
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="browser.open"]').first().click();
await page.waitForTimeout(400);
const g = await page.evaluate(() => {
const tabs = document.querySelector(".pane > .tabs");
const side = document.querySelector('.pane > .scroll[data-pane="browser"]');
const t = tabs.getBoundingClientRect();
const last = [...tabs.querySelectorAll(".tab")].at(-1)?.getBoundingClientRect();
const s = side?.getBoundingClientRect();
const stacked = !!s && s.top >= t.bottom - 1;
const first = tabs.querySelector(".tab")?.getBoundingClientRect();
const pad = parseFloat(getComputedStyle(tabs).paddingLeft) * 2;
const need = last && first ? last.right - first.left + pad : 0;
const pane = document.querySelector(".pane").getBoundingClientRect().width;
return { fits: tabs.scrollWidth <= tabs.clientWidth + 1, clear: stacked || !s || !last || last.right <= s.left + 1, stacked, need, pane };
});
const should = g.pane < g.need + 320 || width <= 640;
if (!g.fits || !g.clear || g.stacked !== should) bad.push(`${width}:${g.fits ? "" : "overflow "}${g.clear ? "" : "overlap "}${g.stacked !== should ? `stacked=${g.stacked} pane=${Math.round(g.pane)} need=${Math.round(g.need)}` : ""}`);
}
check("from 560 to 1440 the tabs fit their row, stay clear of the browser column, and the column stacks exactly when tabs plus its 320px floor no longer fit", bad.length === 0, bad.join(" "));
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+119
View File
@@ -0,0 +1,119 @@
// A column's drag handle must not sit on top of the scrollbar beside it: a press on the thumb has to scroll, and a
// press on the handle has to resize. Hit-testing is a layout question, so it is asked of a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.GUTTER_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
// The scrollbar is 10px wide against the scroller's right edge, its thumb inset 3px on each side.
const THUMB = 5;
const hit = (page, x, y) =>
page.evaluate(([x, y]) => {
const el = document.elementFromPoint(x, y);
const g = el?.closest(".gutter");
return { gutter: g ? g.className : null, tag: el ? el.className || el.tagName : null };
}, [x, y]);
// Every row of the scroller's right edge, not one sample: a handle that only covers the top or the middle is still a
// handle on the thumb whenever the thumb is there.
const sweep = (page, right, top, height) =>
page.evaluate(([right, top, height, inset]) => {
const hits = [];
for (let y = top + 2; y < top + height - 2; y += 12) {
for (const dx of inset) {
const g = document.elementFromPoint(right - dx, y)?.closest(".gutter");
if (g) hits.push(`${g.className.split(" ").at(-1)}@${Math.round(y)}`);
}
}
return hits;
}, [right, top, height, [4, 5, 6]]);
const flowBox = (page) =>
page.evaluate(() => {
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
return { right: r.right, top: r.top, height: r.height };
});
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&turns=30`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const tag = `${scheme} ${width}px`;
const flow = await page.evaluate(() => {
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
return { right: r.right, top: r.top, height: r.height };
});
const dockShut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
check(`${tag}: the workbench starts shut`, dockShut === true);
const covered = await sweep(page, flow.right, flow.top, flow.height);
check(`${tag}: no row of the transcript's scrollbar is under the workbench handle`, covered.length === 0, covered.slice(0, 4).join(" "));
await page.mouse.move(flow.right - 5, flow.top + 8);
await page.mouse.down();
await page.mouse.move(flow.right - 5, flow.top + 160, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(250);
check(`${tag}: pressing the scrollbar's top and dragging does not open the workbench`, (await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"))) === true);
const grip = await page.locator(".gutter-r .grip").boundingBox();
const pane = await page.evaluate(() => {
const r = document.querySelector(".pane").getBoundingClientRect();
return { top: r.top, height: r.height };
});
const mid = pane.top + pane.height / 2;
check(`${tag}: the shut handle sits mid-height`, Math.abs(grip.y + grip.height / 2 - mid) <= pane.height * 0.05, `${Math.round(grip.y + grip.height / 2)} against ${Math.round(mid)}`);
const onGrip = await hit(page, grip.x + grip.width / 2, grip.y + grip.height / 2);
check(`${tag}: the shut workbench still has a handle to press`, onGrip.gutter?.includes("gutter-r") === true, String(onGrip.gutter));
const rail = await page.evaluate(() => {
const el = document.querySelector(".rail .scroll");
const r = el?.getBoundingClientRect();
return r && r.width > 0 ? { right: r.right, top: r.top, height: r.height } : null;
});
if (rail) {
const edge = await hit(page, rail.right - THUMB, rail.top + rail.height * 0.25);
check(`${tag}: the workspace column's scrollbar is not under its handle`, edge.gutter === null, String(edge.gutter));
const seam = await hit(page, rail.right + 3, rail.top + rail.height * 0.75);
check(`${tag}: the workspace column's seam still resizes`, seam.gutter?.includes("gutter-l") === true, String(seam.gutter));
}
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-shut-${scheme}-${width}.png` });
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x - 200, grip.y + grip.height / 2, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(300);
const shut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
check(`${tag}: dragging the handle opens the workbench`, shut === false);
const now = await flowBox(page);
const openCovered = await sweep(page, now.right, now.top, now.height);
check(`${tag}: nor is it once the workbench is open`, openCovered.length === 0, openCovered.slice(0, 4).join(" "));
const seamHit = await hit(page, now.right + 2, now.top + now.height * 0.5);
check(`${tag}: the open seam still resizes`, seamHit.gutter?.includes("gutter-r") === true, String(seamHit.gutter));
const wide = () => page.evaluate(() => document.querySelector('.scroll[data-pane="browser"]').getBoundingClientRect().width);
const before = await wide();
await page.mouse.move(now.right - 5, now.top + 8);
await page.mouse.down();
await page.mouse.move(now.right - 5, now.top + 160, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(250);
check(`${tag}: pressing the scrollbar and dragging leaves the open workbench's width alone`, Math.abs((await wide()) - before) < 1);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-${scheme}-${width}.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+15
View File
@@ -3,7 +3,13 @@ import { boot as bootLang } from "../src/i18n";
import { track as trackWidth } from "../src/ui/viewport";
import "../src/styles/tokens.css";
import "../src/styles/app.css";
import "../src/styles/dock.css";
import "../src/styles/studio.css";
import "../src/styles/tips.css";
import "../src/styles/waitnote.css";
import "../src/styles/narrow.css";
import "../src/styles/workfold.css";
import "../src/styles/workbench-launcher.css";
import "../src/styles/feedback.css";
import { App } from "../src/ui/App";
import { MockHub } from "../src/port/mock_hub";
@@ -78,6 +84,10 @@ class BenchPort extends MockPort {
return ONBOARDING ? { required: true, provider: "", model: "" } : null;
}
async foldWorkSettings() {
return { ...(await super.foldWorkSettings()), fold: query.has("fold") };
}
async workspaceGit() {
const git = await super.workspaceGit();
return query.get("git") === "none" ? { ...git, repo: false } : git;
@@ -209,6 +219,11 @@ const NO_PANES = PANES === 0;
class BenchHub extends MockHub {
readonly feeds = new Map<string, BenchPort>();
private readonly settingsPort = new BenchPort();
machinePort(): AgentPort {
return this.settingsPort;
}
portFor(rt: RuntimeView): AgentPort {
let port = this.feeds.get(rt.id);
+7 -76
View File
@@ -1,85 +1,16 @@
// The transcript skips layout for off-screen cards with content-visibility,
// and each one holds an estimated height until it is first laid out. The
// estimate need not be accurate, but its error must fall on both sides: below
// the shortest card every unread card is underestimated, the error accumulates
// in one direction, and the scroller lands short and then corrects — which is
// the lurch. This measures the real distribution and checks the stylesheet's
// number against it.
// A transcript card's height is never an estimate. Rows near the viewport render, and Block unmounts the far ones
// while holding the height it measured; a card that skipped layout with `content-visibility: auto` held a guess until
// it first scrolled into view and the page then shrank under the wheel. The stylesheet must not bring that back.
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "playwright";
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = process.env.PERF_SRC ?? join(HERE, "..", "src");
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?ws=1&sess=1&turns=40&pref=zh";
const css = readFileSync(join(SRC, "styles", "app.css"), "utf8");
const declared = Number(css.match(/contain-intrinsic-size:\s*auto\s+(\d+(?:\.\d+)?)px/)?.[1] ?? NaN);
if (!Number.isFinite(declared)) {
console.log("未在样式表中找到 contain-intrinsic-size 的兜底值:该检查将始终通过。");
process.exit(1);
}
const css = ["app.css", "studio.css"].map((f) => readFileSync(join(SRC, "styles", f), "utf8")).join("\n");
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: "dark" });
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".compose");
await page.waitForTimeout(1000);
// Walk the whole transcript so every card lays out at least once: only
// then is the distribution the one a reader meets.
const stats = await page.evaluate(async () => {
const sc = [...document.querySelectorAll("*")]
.filter((el) => {
const s = getComputedStyle(el);
return /auto|scroll/.test(s.overflowY) && el.scrollHeight > el.clientHeight + 4 && el.querySelector(".call");
})
.pop();
if (sc) {
for (let y = 0; y <= sc.scrollHeight; y += 400) {
sc.scrollTop = y;
await new Promise((r) => requestAnimationFrame(r));
}
}
const hs = [...document.querySelectorAll(".chunk > .enterbox > .call")]
.map((c) => c.offsetHeight)
.filter((x) => x > 0)
.sort((a, b) => a - b);
if (!hs.length) return null;
const q = (p) => hs[Math.floor((hs.length - 1) * p)];
return {
n: hs.length,
min: hs[0],
median: q(0.5),
mean: Math.round(hs.reduce((a, b) => a + b, 0) / hs.length),
max: hs[hs.length - 1],
};
});
if (!stats) {
console.log("未量到卡片:该检查将始终通过,请先确认页面存在转录内容。");
process.exit(1);
}
console.log(`样式表兜底值 ${declared}px 实测 n=${stats.n} 最小 ${stats.min} 中位数 ${stats.median} 均值 ${stats.mean} 最大 ${stats.max}`);
check(
"占位估算不低于最小卡片高度",
declared >= stats.min,
declared < stats.min ? `${declared} < ${stats.min}:每张未读卡片均被低估` : "",
);
check(
"占位估算处于实测分布区间内",
declared <= stats.max,
declared > stats.max ? `${declared} > ${stats.max}:误差将反向累加` : "",
);
await browser.close();
console.log(fails.length ? `\n${fails.length} 项未通过` : "\n全部通过");
const bad = [...css.matchAll(/([^{}]*\.call[^{}]*)\{([^{}]*)\}/g)].filter((m) => /\.chunk|\.enterbox/.test(m[1]) && /content-visibility\s*:\s*auto/.test(m[2]));
if (bad.length) fails.push("estimated height");
console.log(bad.length ? `FAIL ${bad.map((m) => m[1].trim()).join(" | ")} uses content-visibility: auto` : " ok no .call row in the transcript flow estimates its height");
process.exit(fails.length ? 1 : 0);
+133
View File
@@ -0,0 +1,133 @@
// Under a high interface scale the layout's room is the window divided by the zoom, so a phone-width window can leave
// the shell a couple of hundred CSS pixels. Rows that cannot fit must give something up, never paint over each other.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.NARROW_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const probe = (page) =>
page.evaluate(() => {
const live = (el) => {
const r = el.getBoundingClientRect();
return r.width > 0 && r.height > 0 && getComputedStyle(el).visibility !== "hidden" ? r : null;
};
const name = (el) => (el.className && String(el.className).split(/\s+/)[0]) || el.tagName;
const overlaps = (parent) => {
const boxes = [...parent.children].map((el) => [el, live(el)]).filter(([, r]) => r);
const hits = [];
for (let i = 0; i < boxes.length; i++)
for (let j = i + 1; j < boxes.length; j++) {
const [a, ra] = boxes[i];
const [b, rb] = boxes[j];
const x = Math.min(ra.right, rb.right) - Math.max(ra.left, rb.left);
const y = Math.min(ra.bottom, rb.bottom) - Math.max(ra.top, rb.top);
if (x > 1 && y > 1) hits.push(`${name(a)}/${name(b)}`);
}
return hits;
};
const outside = (parent) =>
[...parent.children].filter((el) => live(el) && live(el).right > innerWidth + 1).map(name);
const bar = document.querySelector(".chrome");
const row = document.querySelector(".compose .row");
const tools = document.querySelector(".compose .turntools");
const crumb = bar.querySelector(".crumb");
const title = crumb.querySelector("b");
const clip = crumb.getBoundingClientRect();
const t = title.getBoundingClientRect();
return {
bar: { overlaps: overlaps(bar).concat(overlaps(bar.querySelector(".r"))), outside: outside(bar).concat(outside(bar.querySelector(".r"))) },
row: { overlaps: overlaps(row), outside: outside(row) },
toolsOverflow: [...tools.children].filter((el) => live(el) && live(el).right > tools.getBoundingClientRect().right + 1).map(name),
titleShown: t.width,
titleOutsideCrumb: t.width > 0 && (t.left < clip.left - 1 || t.right > clip.right + 1),
crumbWidth: clip.width,
room: document.body.clientWidth,
};
});
const CONTROLS = [".phone-action", ".find-action", ".theme-toggle", ".top-action", ".browser-action"];
// Opens the fold when there is one, then presses nothing: asks whether each control is on screen with nothing over it.
async function reachable(page) {
const more = page.locator(".chrome .r-more");
if (await more.isVisible()) {
await more.click();
await page.waitForTimeout(150);
}
return page.evaluate((controls) => {
const out = [];
for (const sel of controls) {
const el = document.querySelector(`.chrome ${sel}`);
if (!el) {
out.push(`${sel} missing`);
continue;
}
const r = el.getBoundingClientRect();
const top = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
const inside = r.width > 0 && r.left >= 0 && r.right <= innerWidth + 1;
if (!inside || !(el === top || el.contains(top))) out.push(`${sel}${inside ? " covered" : " off screen"}`);
}
return out;
}, CONTROLS);
}
const browser = await chromium.launch();
// The fold's threshold is derived, not chosen: wherever the bar is not folded the title keeps its floor.
{
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
const bad = [];
for (let room = 190; room <= 460; room += 10) {
await page.setViewportSize({ width: room, height: 800 });
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.waitForTimeout(200);
const w = await page.evaluate(() => document.querySelector(".chrome .crumb").getBoundingClientRect().width);
if (w < 48) bad.push(`${room}:${Math.round(w)}`);
}
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
await page.close();
}
for (const scheme of ["light", "dark"]) {
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [1280, 1]]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&turns=2&zoom=${zoom}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
await page.waitForTimeout(500);
const tag = `${scheme} ${width}px zoom ${zoom}`;
const m = await probe(page);
check(`${tag}: title bar controls do not overlap`, m.bar.overlaps.length === 0, m.bar.overlaps.join(", "));
check(`${tag}: title bar stays inside the window`, m.bar.outside.length === 0, m.bar.outside.join(", "));
check(`${tag}: composer row does not overlap`, m.row.overlaps.length === 0, m.row.overlaps.join(", "));
check(`${tag}: composer toolbar controls stay in the toolbar`, m.toolsOverflow.length === 0, m.toolsOverflow.join(", "));
check(`${tag}: the title keeps its 4em floor inside its own box`, m.crumbWidth >= 48 && !m.titleOutsideCrumb, `crumb ${Math.round(m.crumbWidth)}px`);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-${scheme}-${width}-z${zoom}.png` });
const hint = await page.evaluate(() => {
const el = document.querySelector(".compose textarea");
const cs = getComputedStyle(el);
const ctx = document.createElement("canvas").getContext("2d");
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
const room = el.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
return { text: el.placeholder, width: ctx.measureText(el.placeholder).width, room };
});
check(`${tag}: the composer's placeholder is on one line and not empty`, hint.text.length > 0 && hint.width <= hint.room + 1, JSON.stringify(hint));
const reach = await reachable(page);
check(`${tag}: every title-bar control can be reached, folded or not`, reach.length === 0, reach.join(", "));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.NARROW_TAG ?? "shot"}-open-${scheme}-${width}-z${zoom}.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+7 -2
View File
@@ -1,4 +1,4 @@
// Icon-rail hover hints: each must be drawn, fully inside the window, and not clipped by any ancestor.
// Icon-rail hover hints (drawn after a short hover delay): each must be drawn, fully inside the window, and not clipped by any ancestor.
// Clipping is a layout question jsdom cannot answer. Modes are the stored rail choice; "collapsed" also closes the workspace rail.
import { chromium } from "playwright";
@@ -58,7 +58,12 @@ for (const scheme of ["light", "dark"]) {
for (let i = 0; i < count; i++) {
const btn = page.locator(".nav .navbtn").nth(i);
await btn.hover();
await page.waitForTimeout(300);
if (i === 0) {
await page.waitForTimeout(120);
const early = await measure(page, i);
check(`${scheme}/${mode}: the hint is not drawn the instant the pointer arrives`, early && early.opacity < 1, `opacity ${early?.opacity}`);
}
await page.waitForTimeout(700);
const m = await measure(page, i);
const tag = `${scheme}/${mode}/#${i} ${m?.text ?? "?"}`;
if (!m) { check(`${tag}: hint exists`, false); continue; }
+54
View File
@@ -0,0 +1,54 @@
// A long runtime notice has to be readable whole and dismissible without scrolling.
// Reads geometry: the bar's sentence is not clipped by .composenotes and its dismiss
// button lies inside the visible part of it.
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${PAGE}?turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 20000 });
await page.evaluate(() => {
window.__feed({
kind: "notice",
level: "warn",
audience: "operator",
code: "permission_rules_dormant",
text: "1 permission rule matches no tool",
detail: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
});
});
await page.waitForSelector(".rtbar", { timeout: 5000 });
await page.waitForTimeout(1500);
for (const [w, h] of [[1440, 900], [400, 800], [1100, 640]]) {
await page.setViewportSize({ width: w, height: h });
await page.waitForTimeout(900);
const g = await page.evaluate(() => {
const notes = document.querySelector(".composenotes");
const nr = notes.getBoundingClientRect();
const btn = document.querySelector(".rtbar button").getBoundingClientRect();
return {
clipped: document.querySelector(".rtbar").getBoundingClientRect().bottom > nr.bottom + 1,
btnIn: btn.top >= nr.top - 1 && btn.bottom <= nr.bottom + 1 && btn.right <= innerWidth + 1,
sh: notes.scrollHeight, ch: notes.clientHeight,
text: document.querySelector(".rtbar .t").textContent,
};
});
// A short window has no room for a long sentence; it scrolls, and the button stays on the first row.
if (h >= 720) check(`${w}x${h}: sentence is not clipped`, !g.clipped, `scroll ${g.sh} / client ${g.ch}`);
check(`${w}x${h}: dismiss button is visible without scrolling`, g.btnIn);
check(`${w}x${h}: no raw placeholder`, !/\{\w+\}/.test(g.text), g.text.slice(0, 30));
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+3 -1
View File
@@ -36,9 +36,11 @@ for (const scheme of ["light", "dark"]) {
const g = await page.evaluate(() => {
const el = document.querySelector(".reask textarea");
if (!el) return null;
return { h: el.getBoundingClientRect().height, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
const row = el.closest(".call").getBoundingClientRect();
return { h: el.getBoundingClientRect().height, w: el.getBoundingClientRect().width, row: row.width, scroll: el.scrollHeight, client: el.clientHeight, view: innerHeight };
});
check(`${scheme}/${label}: the rewrite box opened`, !!g);
if (g) check(`${scheme}/${label}: the rewrite box spans its row, not the width a bare textarea starts at`, g.w >= g.row * 0.9 - 2, `box ${Math.round(g.w)}px of a ${Math.round(g.row)}px row`);
if (g) {
const room = Math.min(g.scroll, g.view * 0.6, 448);
check(`${scheme}/${label}: long single-line text is visible without scrolling the box`, g.client >= room - 2, `box ${Math.round(g.h)}px, content ${g.scroll}px`);
@@ -0,0 +1,29 @@
// A running call with no output yet: its row opens, says it is still running and for how long, and names its own timeout.
import { chromium } from "playwright";
const fails = [];
const check = (name, ok, detail = "") => { console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`); if (!ok) fails.push(name); };
const [,, out, w] = process.argv;
const b = await chromium.launch();
const p = await b.newPage({ viewport: { width: +w || 1100, height: 700 } });
await p.goto((process.env.PERF_URL ?? "http://localhost:4521/perf.html") + "?pref=zh", { waitUntil: "networkidle" }); await p.waitForSelector(".app"); await p.waitForTimeout(400);
await p.evaluate(async () => {
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "list them" });
window.__feed({ kind: "text", text: "我来看一下目录。" });
const cmd = 'cd /Users/me/Documents/AI项目/音频搜索 && for f in *.md; do echo "=== $f ($(wc -l < "$f") 行)"; head -20 "$f"; done; ls -la /Users/me/Documents/ ; echo ---; find /Users/me/Documents -maxdepth 3 -name "*.md" | head -50';
window.__feed({ kind: "tool_dispatch", tool: { id: "b1", name: "bash", args: JSON.stringify({ command: cmd, timeout_seconds: 900 }) } });
await new Promise((r) => setTimeout(r, 300));
});
await p.waitForTimeout(600);
if (out) await p.screenshot({ path: out + "-1.png" });
const row = p.locator(".activity-group .call").first();
check("the running row is a disclosure", (await row.locator("details.tool-disclosure").count()) === 1);
await row.locator("summary").click().catch(() => {});
await p.waitForTimeout(1500);
const g = await p.evaluate(() => ({ note: document.querySelector(".activity-call-wait")?.textContent ?? "", input: !!document.querySelector(".activity-call-input"), title: document.querySelector(".activity-group .arg")?.getAttribute("title") ?? "" }));
check("opened, it shows the whole command", g.input && g.title.includes("maxdepth 3"));
check("and says it is still running with no output, and for how long", /命令仍在运行/.test(g.note) && /已运行/.test(g.note), g.note);
check("and names the command's own timeout", /超时上限 900 秒/.test(g.note), g.note);
if (out) await p.screenshot({ path: out + "-2.png" });
await b.close();
console.log(fails.length ? `\n${fails.length} failed` : "\nall ok");
process.exit(fails.length ? 1 : 0);
+83
View File
@@ -0,0 +1,83 @@
// One sidebar toggle, drawn at one place whatever the sidebar and the icon rail are doing, never under another control;
// and a run of replies from one model is headed once. Position and hit-testing are questions for a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SIDEBAR_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const mode of ["on", "collapsed", "off"]) {
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.addInitScript((m) => localStorage.setItem("rx-nav-rail", m), mode);
await page.goto(`${BASE}?pref=en&turns=3`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const tag = `${scheme}/${mode}`;
const spot = async () => {
await page.waitForTimeout(700);
return page.evaluate(() => {
const toggles = [...document.querySelectorAll('[data-action="chrome.rail"]')].filter((b) => b.tabIndex !== -1 && b.getBoundingClientRect().width > 0 && b.getBoundingClientRect().right > 0);
const b = toggles[0]?.getBoundingClientRect();
if (!b) return { count: toggles.length };
const x = b.left + b.width / 2;
const y = b.top + b.height / 2;
const top = document.elementFromPoint(x, y);
return { count: toggles.length, x: Math.round(x), y: Math.round(y), reached: toggles[0].contains(top) };
});
};
const open = await spot();
check(`${tag}: exactly one sidebar toggle is drawn while the sidebar is open`, open.count === 1, String(open.count));
check(`${tag}: nothing lies over it`, open.reached === true);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
await page.locator('[data-action="chrome.rail"]').filter({ visible: true }).first().click({ position: { x: 15, y: 15 } });
const shut = await spot();
check(`${tag}: exactly one toggle after collapsing`, shut.count === 1, String(shut.count));
check(`${tag}: it has not moved`, shut.x === open.x && shut.y === open.y, `${open.x},${open.y} -> ${shut.x},${shut.y}`);
check(`${tag}: nothing lies over it once collapsed`, shut.reached === true);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-shut-${tag.replace("/", "-")}.png` });
await page.close();
}
}
for (const scheme of ["light", "dark"]) {
const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(async () => {
const say = (text) => {
window.__feed({ kind: "text", text });
window.__feed({ kind: "message" });
};
window.__feed({ kind: "turn_started" });
say("First reply.");
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 5 } });
say("Second reply, same model.");
say("Third reply, same model.");
window.__feed({ kind: "turn_done" });
});
await page.waitForTimeout(600);
const heads = await page.evaluate(() =>
[...document.querySelectorAll('.flow .call[data-k="say"]')].map((c) => {
const hl = c.querySelector(":scope > .c > .hl");
const sym = c.querySelector(":scope > .g");
return { quiet: c.hasAttribute("data-quiet"), head: !!hl && getComputedStyle(hl).display !== "none", mark: !!sym && getComputedStyle(sym).display !== "none" };
}),
);
check(`${scheme}: three replies in a turn, one header`, heads.length === 3 && heads.filter((h) => h.head && h.mark).length === 1 && heads[0].head && heads[0].mark, JSON.stringify(heads));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIDEBAR_TAG ?? "shot"}-replies-${scheme}.png` });
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+46
View File
@@ -0,0 +1,46 @@
// The signal rail of the run analysis says what it is and why its marks cannot be moved.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.SIGNALS_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [label, width] of [["wide", 1280], ["phone", 390]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => {
window.__feed({ kind: "turn_started" });
window.__feed({ kind: "retrying", retryAttempt: 1, retryMax: 2, retryScope: "stream" });
window.__feed({ kind: "tool_dispatch", tool: { id: "t1", name: "bash", args: "{}" } });
window.__feed({ kind: "tool_result", tool: { id: "t1", name: "bash", args: "{}", output: "ok", durationMs: 40 } });
window.__feed({ kind: "turn_done" });
});
await page.locator('[data-action="pane.view"][data-value="analysis"]').click();
await page.waitForSelector(".run-signals");
const tag = `${scheme}/${label}`;
const g = await page.evaluate(() => {
const note = document.querySelector(".run-signals .run-section-note");
const r = note?.getBoundingClientRect();
const box = document.querySelector(".run-signals").getBoundingClientRect();
return { text: note?.textContent ?? "", inside: !!r && r.left >= box.left - 1 && r.right <= box.right + 1 && r.width > 0 };
});
check(`${tag}: the rail says what its marks are and why they stay put`, /位置就是时间|position is their time/.test(g.text), g.text);
check(`${tag}: the explanation sits inside the section`, g.inside);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.SIGNALS_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+57
View File
@@ -0,0 +1,57 @@
// The workbench tab opens a launcher instead of a browser; the title-bar Browser button still goes straight to one.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.START_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [label, width] of [["wide", 1440], ["narrow", 900]]) {
const page = await browser.newPage({ viewport: { width, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
const tag = `${scheme}/${label}`;
const tabs = () => page.evaluate(() => document.querySelectorAll(".workbench-tab").length);
await page.locator('[data-action="browser.open"]').first().click();
await page.waitForTimeout(700);
const direct = await page.evaluate(() => ({ launcher: !!document.querySelector(".workbench-launcher"), tabs: document.querySelectorAll(".workbench-tab").length }));
check(`${tag}: the title-bar Browser button goes straight to a browser`, direct.tabs === 1 && !direct.launcher, JSON.stringify(direct));
await page.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.locator('[data-action="pane.view"][data-value="browser"]').click();
await page.waitForTimeout(700);
const start = await page.evaluate(() => {
const box = document.querySelector(".workbench-launcher");
const r = box?.getBoundingClientRect();
return {
shown: !!r && r.width > 0,
inside: !!r && r.left >= 0 && r.right <= innerWidth && r.top >= 0 && r.bottom <= innerHeight,
choices: [...(box?.querySelectorAll("button") ?? [])].map((b) => b.textContent.trim()),
tabs: document.querySelectorAll(".workbench-tab").length,
};
});
check(`${tag}: the Workbench tab opens the launcher`, start.shown, JSON.stringify(start));
check(`${tag}: it offers files, changes and the browser`, start.choices.length === 3 && ["Files", "Changes", "Browser"].every((c, i) => start.choices[i].startsWith(c)), start.choices.join(" | "));
check(`${tag}: no browser has been opened for the person`, start.tabs === 0, String(start.tabs));
check(`${tag}: the launcher is inside the window`, start.inside);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.START_TAG ?? "shot"}-${tag.replace("/", "-")}.png` });
await page.locator(".workbench-launcher button", { hasText: "Browser" }).click().catch(() => {});
await page.waitForTimeout(500);
const after = await page.evaluate(() => ({ tabs: document.querySelectorAll(".workbench-tab").length, launcher: !!document.querySelector(".workbench-launcher") }));
check(`${tag}: choosing the browser opens one and the launcher steps aside`, after.tabs === 1 && !after.launcher, JSON.stringify(after));
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+4 -5
View File
@@ -1,10 +1,9 @@
// 滚轮轨迹的探针:在一份很高的两轮转录里一路向上滚,scrollTop 在滚轮向上时
// 不许往下走、单步不许比滚轮多走、scrollHeight 不许缩水。
//
// 目前只报告、不判红:`.call` 的 `content-visibility: auto` 配上
// `contain-intrinsic-size: auto 216px`,会在块第一次进入视口时把预估高度改成
// 实测高度,scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。修复
// 落地后把 `WHEEL_STRICT` 的默认值翻成 1,这条探针就成为那次修复的验收。
// `.call` 不再带 `content-visibility: auto`:它按预估高度占位、首次进入视口时改成实测高度,
// scrollHeight 随之缩水(12975 → 11217),内容在手指下跳动。块的卸载与占位由 Block 负责。
// 默认严格;WHEEL_STRICT=0 只报告。
import { chromium } from "playwright";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
@@ -12,7 +11,7 @@ const STEP = Number(process.env.WHEEL_STEP ?? 100);
const TURNS = Number(process.env.WHEEL_TURNS ?? 2);
const CALLS = Number(process.env.WHEEL_CALLS ?? 6);
const WAIT = Number(process.env.WHEEL_WAIT ?? 120);
const STRICT = (process.env.WHEEL_STRICT ?? "0") === "1";
const STRICT = (process.env.WHEEL_STRICT ?? "1") === "1";
const browser = await chromium.launch();
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width: 1440, height: 900 } });
+132
View File
@@ -0,0 +1,132 @@
// A finished turn's work, folded under one line when the kernel's setting asks for it: the answer stays outside, the
// line opens on click and keeps its state, a failure shows on the line, and the running turn is never folded.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.FOLD_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const feedTurn = (page, { fail = false, done = true } = {}) =>
page.evaluate(async ({ fail, done }) => {
const frame = () => new Promise((r) => requestAnimationFrame(r));
window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 0, text: "Please fix the failing test" });
window.__feed({ kind: "text", text: "I will read the test first." });
window.__feed({ kind: "message", text: "I will read the test first." });
for (const [i, name] of ["read_file", "bash", "edit_file"].entries()) {
const tool = { id: `t${i}`, name, args: JSON.stringify({ path: `pkg/f${i}.go` }) };
window.__feed({ kind: "tool_dispatch", tool });
window.__feed({ kind: "tool_result", tool: { ...tool, output: fail && i === 1 ? "exit 1" : "ok", durationMs: 120, ...(fail && i === 1 ? { err: "exit status 1" } : {}) } });
await frame();
}
window.__feed({ kind: "text", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
window.__feed({ kind: "message", text: "The cause was an off-by-one in the loop bound.\n\nI changed it and the test passes." });
if (done) window.__feed({ kind: "turn_done" });
await frame();
}, { fail, done });
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const [label, width] of [["wide", 1280], ["narrow", 420]]) {
const tag = `${scheme}/${label}`;
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await feedTurn(page, { fail: true });
await page.waitForTimeout(700);
const g = await page.evaluate(() => {
const fold = document.querySelector("details.work-fold");
const final = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
const r = fold?.getBoundingClientRect();
return {
folds: document.querySelectorAll("details.work-fold").length,
open: fold?.open,
failed: fold?.hasAttribute("data-failed"),
line: fold?.querySelector("summary")?.textContent ?? "",
bodyHidden: fold ? fold.querySelector(".work-fold-body").getBoundingClientRect().height === 0 || getComputedStyle(fold.querySelector(".work-fold-body")).display === "none" || !fold.open : null,
answerOutside: !!final && !fold?.contains(final) && final.textContent.includes("off-by-one"),
inside: !!r && r.left >= 0 && r.right <= innerWidth,
};
});
check(`${tag}: the finished turn has one folded line`, g.folds === 1 && g.open === false, JSON.stringify(g));
check(`${tag}: the answer stays outside it`, g.answerOutside);
check(`${tag}: the line counts the calls and marks the failure`, /3/.test(g.line) && g.failed === true, g.line);
check(`${tag}: the line fits the window`, g.inside);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-folded-${tag.replace("/", "-")}.png` });
await page.locator("details.work-fold > summary").click();
await page.waitForTimeout(500);
check(`${tag}: a click opens it and the steps show`, await page.evaluate(() => document.querySelector("details.work-fold").open && document.querySelectorAll("details.work-fold .call").length >= 3));
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.FOLD_TAG ?? "shot"}-open-${tag.replace("/", "-")}.png` });
await page.close();
}
}
{
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`${BASE}?pref=en&fold`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await feedTurn(page, { done: false });
await page.waitForTimeout(500);
check("a turn still running is never folded", (await page.locator("details.work-fold").count()) === 0);
await page.evaluate(() => window.__feed({ kind: "turn_done" }));
await page.waitForTimeout(500);
check("it folds when the turn settles", (await page.locator("details.work-fold").count()) === 1);
await page.close();
}
{
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto(`${BASE}?pref=en`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await feedTurn(page);
await page.waitForTimeout(500);
check("with the setting off nothing is folded", (await page.locator("details.work-fold").count()) === 0);
await page.close();
}
{
const page = await browser.newPage({ viewport: { width: 1280, height: 700 } });
await page.goto(`${BASE}?pref=en&fold&turns=30`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await feedTurn(page, { done: false });
await page.waitForTimeout(600);
await page.evaluate(() => { const sc = document.querySelector('[data-pane="flow"]'); sc.scrollTop = sc.scrollHeight; });
await page.waitForTimeout(300);
const before = await page.evaluate(() => {
const sc = document.querySelector('[data-pane="flow"]');
const answer = [...document.querySelectorAll('.call[data-k="say"]')].at(-1).getBoundingClientRect();
return { top: sc.scrollTop, h: sc.scrollHeight, answerTop: Math.round(answer.top), view: innerHeight };
});
const frames = await page.evaluate(async () => {
const sc = document.querySelector('[data-pane="flow"]');
const seen = [];
const sample = () => {
const a = [...document.querySelectorAll('.call[data-k="say"]')].at(-1);
const fold = document.querySelector("details.work-fold");
seen.push({ folds: fold ? 1 : 0, open: !!fold?.open, answerTop: Math.round(a.getBoundingClientRect().top), top: sc.scrollTop, h: sc.scrollHeight });
};
window.__feed({ kind: "turn_done" });
for (let i = 0; i < 12; i++) {
await new Promise((r) => requestAnimationFrame(r));
sample();
}
return seen;
});
const first = frames[0];
const last = frames.at(-1);
const answerMoves = new Set(frames.map((f) => f.answerTop)).size;
console.log(`settle: before answerTop=${before.answerTop} scrollHeight=${before.h}; first frame ${JSON.stringify(first)}; last ${JSON.stringify(last)}; distinct answer positions over 12 frames: ${answerMoves}`);
check("the work is folded in the first frame after the turn ends, never drawn open and then closed", first.folds === 1 && frames.every((f) => !f.open));
check("the answer settles in one move rather than wandering", answerMoves <= 2, String(answerMoves));
check("the answer is still on screen after the fold", last.answerTop >= 0 && last.answerTop < before.view, String(last.answerTop));
check("a reader pinned to the bottom stays there: the page and the scroll position shrink together", Math.abs(last.top + before.view - last.h) < before.view && last.answerTop > 0, JSON.stringify({ top: last.top, h: last.h }));
await page.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+167 -148
View File
@@ -48,8 +48,8 @@ importers:
specifier: ^6.1.3
version: 6.1.3
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
specifier: ^6.13.1
version: 6.13.1
'@codemirror/search':
specifier: ^6.7.2
version: 6.7.2
@@ -57,8 +57,8 @@ importers:
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
specifier: ^6.43.13
version: 6.43.13
specifier: ^6.43.14
version: 6.43.14
'@lezer/highlight':
specifier: ^1.2.5
version: 1.2.5
@@ -66,8 +66,8 @@ importers:
specifier: ^6.0.2
version: 6.0.2
katex:
specifier: ^0.18.10
version: 0.18.10
specifier: ^0.19.0
version: 0.19.0
react:
specifier: ^19.3.0
version: 19.3.0
@@ -100,8 +100,8 @@ importers:
version: 6.0.0
devDependencies:
'@babel/parser':
specifier: ^8.0.6
version: 8.0.6
specifier: ^8.0.7
version: 8.0.7
'@testing-library/react':
specifier: ^16.3.3
version: 16.3.3(@testing-library/[email protected])(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -115,32 +115,32 @@ importers:
specifier: ^19.3.0
version: 19.3.0(@types/[email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected].2([email protected]))
specifier: ^6.1.2
version: 6.1.2([email protected].3([email protected]))
jsdom:
specifier: ^30.1.1
version: 30.1.1
specifier: ^30.1.2
version: 30.1.2
playwright:
specifier: ^1.63.0
version: 1.63.0
specifier: ^1.64.0
version: 1.64.0
typescript:
specifier: ^7.0.2
version: 7.0.2
vite:
specifier: ^8.3.2
version: 8.3.2([email protected])
specifier: ^8.3.3
version: 8.3.3([email protected])
vitest:
specifier: ^5.0.3
version: 5.0.3([email protected].1)([email protected].2([email protected]))
version: 5.0.3([email protected].2)([email protected].3([email protected]))
packages:
'@asamuzakjp/css-color@7.0.1':
resolution: {integrity: sha512-C9duntabagkBZ1LebM7FKmphR4Q1pBclLxVbZETQV0akkFjV0ooFxo8FlvAQyKj9F6l8rEnmidgcTlpyxFYizg==}
'@asamuzakjp/css-color@7.1.3':
resolution: {integrity: sha512-1t1U8Cm3RBl9vh3RAZhjkPaduzLSvQJRToRrxpGR/eDRW99mOREleuu+mWfiFa795bUzjwht7NgphCKTs2ianQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@asamuzakjp/[email protected].1':
resolution: {integrity: sha512-NT4s3yZLjovPpliRpTvdsdzyPjqRqiCZj9MxnarBihbaY5MbAG7DWAJcrLlhmC7xKTNCXsTELcqB8YsqpLnUFQ==}
'@asamuzakjp/[email protected].4':
resolution: {integrity: sha512-YnVzxDxLaqL0t7Q4wfcgHZjg55Wm6y5EaO7Bhu3q9J7wq/8iw3PhsnWiOQhYekx4VexnbkgWpTT6n+QIV7kczw==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
'@babel/[email protected]':
@@ -159,8 +159,8 @@ packages:
resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==}
engines: {node: ^22.18.0 || >=24.11.0}
'@babel/[email protected].6':
resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==}
'@babel/[email protected].7':
resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==}
engines: {node: ^22.18.0 || >=24.11.0}
hasBin: true
@@ -218,8 +218,8 @@ packages:
'@codemirror/[email protected]':
resolution: {integrity: sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==}
'@codemirror/[email protected]2.4':
resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==}
'@codemirror/[email protected]3.1':
resolution: {integrity: sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==}
'@codemirror/[email protected]':
resolution: {integrity: sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==}
@@ -230,43 +230,46 @@ packages:
'@codemirror/[email protected]':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/[email protected]':
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@codemirror/[email protected]':
resolution: {integrity: sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==}
'@csstools/color-helpers@6.1.1':
resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==}
'@codemirror/view@6.43.14':
resolution: {integrity: sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==}
'@csstools/[email protected]':
resolution: {integrity: sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==}
engines: {node: '>=20.19.0'}
'@csstools/[email protected].0':
resolution: {integrity: sha512-XQKj5B7QiZcHiegCOCAzcAOJdhGgWOHbbu62h5e5mkHnn8lWcfiJhllkqWmxu5zWR9jucPHuo1iTB56P033hcg==}
'@csstools/[email protected].3':
resolution: {integrity: sha512-iex20d8CHVkyvg6B7UKV7uHnI2Bqo9g+EFfT9E0y+GvTvhZ/DwONJ+9aKb1dlqm0ZiGsL5RXjp0fCoJYnkeDjA==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms': ^4.0.2
'@csstools/css-tokenizer': ^4.0.2
'@csstools/[email protected].3':
resolution: {integrity: sha512-y4LpL+lmpuyKDiEFq2PnZUVFdAjsoB/qQJod79yLNokXyW7jewi+/WJ69EfItj8A2unWtxXnGjw6LYXgXu5ZjA==}
'@csstools/[email protected].6':
resolution: {integrity: sha512-iiPQ3iRWwnJkeEn6RIu6SJPr7hYrLz6XZ9s/QZl+2/LI5KQVjpl2fdmDSZKuD4xP6GMmMPgHFFXg6k1Wkz0Trg==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms': ^4.0.2
'@csstools/css-tokenizer': ^4.0.2
'@csstools/[email protected].0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
'@csstools/[email protected].2':
resolution: {integrity: sha512-40cSKyMvK+tq4qz6Awrlye2WGuOKt3FwPgtGg6KTfbHOWNw+Rk1rzbAtZnZ6IBhsY491HLRnDXwoyBAijmmILA==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-tokenizer': ^4.0.2
'@csstools/[email protected]4':
resolution: {integrity: sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==}
'@csstools/[email protected]5':
resolution: {integrity: sha512-J0u7HkVl2nzSlhsiTOp4AmwcUQ3D+mGEEKfBy/7To5/y7F2OHwyLrXfrhR0SMgr4p5Lo+eaMVSeai24zUcBIxA==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/[email protected].1':
resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==}
'@csstools/[email protected].2':
resolution: {integrity: sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==}
engines: {node: '>=20.19.0'}
'@esbuild/[email protected]':
@@ -434,6 +437,15 @@ packages:
'@noble/hashes':
optional: true
'@exodus/[email protected]':
resolution: {integrity: sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@jridgewell/[email protected]':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@@ -783,13 +795,13 @@ packages:
'@ungap/[email protected]':
resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==}
'@vitejs/[email protected].1':
resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==}
'@vitejs/[email protected].2':
resolution: {integrity: sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
'@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
babel-plugin-react-compiler: ^1.0.0
oxc-transform-react: ^0.145.0
oxc-transform-react: ^0.152.0
vite: ^8.0.0
peerDependenciesMeta:
'@rolldown/plugin-babel':
@@ -877,9 +889,9 @@ packages:
[email protected]:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
data-urls@8.0.0:
resolution: {integrity: sha512-JQUCCK+/CAGbGGx618PlFZMXH81Jtj7t73CHv9dd/zVWvxgGT2zLFRjnKb+Ng46GIwcy+f7f88qd4duqllK+WQ==}
engines: {node: ^22.14.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
@@ -1038,8 +1050,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
[email protected].1:
resolution: {integrity: sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==}
[email protected].2:
resolution: {integrity: sha512-0FFE/jE1rppmVfUrJUgxqXjcwolZYIGtAgj1pTussMhNZxIxi7W/PvfWaMNroGi2B36X1IKHbexpZ9DhkoOPiQ==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
@@ -1051,8 +1063,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected]8.10:
resolution: {integrity: sha512-/B6p9eY9DX7aHBfpkHdpirDTZ5QH9xTwL9y837PWuzuv41O5jFdqNQwVQEQsimY0/iVNkwBY4+4hMfhQ7d4Dbw==}
[email protected]9.0:
resolution: {integrity: sha512-v6Tznz3zJ7u3niRCoDTsumM2+HA2XXcCu+WAacCeHD2z3p9A9Ks987o5FzfTGBN0e8A0vjEgIDvLbICrXpdw/Q==}
hasBin: true
[email protected]:
@@ -1135,10 +1147,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==}
[email protected]:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
[email protected]:
resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==}
engines: {node: 20 || >=22}
@@ -1319,13 +1327,13 @@ packages:
resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==}
engines: {node: '>=12'}
[email protected]3.0:
resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==}
[email protected]4.0:
resolution: {integrity: sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==}
engines: {node: '>=20'}
hasBin: true
[email protected]3.0:
resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==}
[email protected]4.0:
resolution: {integrity: sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==}
engines: {node: '>=20'}
hasBin: true
@@ -1457,6 +1465,10 @@ packages:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-BcDSDW+SIGWVOns1Zm6Yg91q9AEj7eaID0QDY9oIqJD1A8dscNwMcl9Sms2DeuWCNov1X0/oXanHGYQBSLwZsg==}
engines: {node: ^22.14.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -1468,8 +1480,8 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
[email protected]0.2:
resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==}
[email protected]1.2:
resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==}
engines: {node: '>=22.19.0'}
[email protected]:
@@ -1505,8 +1517,8 @@ packages:
[email protected]:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
[email protected].2:
resolution: {integrity: sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==}
[email protected].3:
resolution: {integrity: sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
@@ -1607,14 +1619,14 @@ packages:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
[email protected]:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==}
engines: {node: ^22.14.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-IhLIHNcBrzIixC5XXsbr4DxdjajyA5vQ5RwGZMtrUp+GbtE5Nrsge9QKau7jXrC0aTXLYGV0xiW7Tb4YS2aomw==}
engines: {node: ^22.14.0 || >=24.0.0}
[email protected]:
resolution: {integrity: sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==}
engines: {node: '>=20.11'}
@@ -1632,15 +1644,15 @@ packages:
snapshots:
'@asamuzakjp/css-color@7.0.1':
'@asamuzakjp/css-color@7.1.3':
dependencies:
'@csstools/css-calc': 3.4.0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
'@csstools/css-color-parser': 4.2.3(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
'@csstools/css-tokenizer': 4.0.1
'@csstools/css-calc': 3.4.3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
'@csstools/css-color-parser': 4.2.6(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
'@csstools/css-tokenizer': 4.0.2
lru-cache: 11.5.3
'@asamuzakjp/[email protected].1':
'@asamuzakjp/[email protected].4':
dependencies:
bidi-js: 1.1.0
css-tree: 3.2.1
@@ -1659,7 +1671,7 @@ snapshots:
'@babel/[email protected]': {}
'@babel/[email protected].6':
'@babel/[email protected].7':
dependencies:
'@babel/types': 8.0.6
@@ -1676,22 +1688,22 @@ snapshots:
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
@@ -1699,7 +1711,7 @@ snapshots:
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
@@ -1709,60 +1721,60 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/css': 1.3.8
'@lezer/html': 1.3.13
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@lezer/java': 1.1.4
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@lezer/json': 1.0.3
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
'@codemirror/[email protected]':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@lezer/rust': 1.0.3
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
@@ -1771,26 +1783,27 @@ snapshots:
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
'@codemirror/[email protected]':
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
'@lezer/yaml': 1.0.4
'@codemirror/[email protected]2.4':
'@codemirror/[email protected]3.1':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/streamparser': 6.0.0
'@codemirror/view': 6.43.14
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.5
'@lezer/lr': 1.4.10
@@ -1799,49 +1812,51 @@ snapshots:
'@codemirror/[email protected]':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
crelt: 1.0.7
'@codemirror/[email protected]':
dependencies:
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
crelt: 1.0.7
'@codemirror/[email protected]':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/[email protected]':
'@codemirror/[email protected]': {}
'@codemirror/[email protected]':
dependencies:
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
'@csstools/[email protected].1': {}
'@csstools/[email protected].2': {}
'@csstools/[email protected].0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)':
'@csstools/[email protected].3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
'@csstools/css-tokenizer': 4.0.1
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
'@csstools/css-tokenizer': 4.0.2
'@csstools/[email protected].3(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)':
'@csstools/[email protected].6(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)':
dependencies:
'@csstools/color-helpers': 6.1.1
'@csstools/css-calc': 3.4.0(@csstools/[email protected].0(@csstools/[email protected].1))(@csstools/[email protected].1)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/[email protected].1)
'@csstools/css-tokenizer': 4.0.1
'@csstools/color-helpers': 6.1.2
'@csstools/css-calc': 3.4.3(@csstools/[email protected].2(@csstools/[email protected].2))(@csstools/[email protected].2)
'@csstools/css-parser-algorithms': 4.0.2(@csstools/[email protected].2)
'@csstools/css-tokenizer': 4.0.2
'@csstools/[email protected].0(@csstools/[email protected].1)':
'@csstools/[email protected].2(@csstools/[email protected].2)':
dependencies:
'@csstools/css-tokenizer': 4.0.1
'@csstools/css-tokenizer': 4.0.2
'@csstools/[email protected]4([email protected])':
'@csstools/[email protected]5([email protected])':
optionalDependencies:
css-tree: 3.2.1
'@csstools/[email protected].1': {}
'@csstools/[email protected].2': {}
'@esbuild/[email protected]':
optional: true
@@ -1923,6 +1938,8 @@ snapshots:
'@exodus/[email protected]': {}
'@exodus/[email protected]': {}
'@jridgewell/[email protected]': {}
'@jridgewell/[email protected]': {}
@@ -2188,19 +2205,19 @@ snapshots:
'@ungap/[email protected]': {}
'@vitejs/[email protected].1([email protected].2([email protected]))':
'@vitejs/[email protected].2([email protected].3([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.3.2([email protected])
vite: 8.3.3([email protected])
'@vitest/[email protected]([email protected].2([email protected]))':
'@vitest/[email protected]([email protected].3([email protected]))':
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@vitest/spy': 5.0.3
estree-walker: 3.0.3
magic-string: 1.4.2
optionalDependencies:
vite: 8.3.2([email protected])
vite: 8.3.3([email protected])
'@vitest/[email protected]': {}
@@ -2236,11 +2253,11 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/language': 6.13.1
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@codemirror/view': 6.43.14
[email protected]: {}
@@ -2257,10 +2274,10 @@ snapshots:
[email protected]: {}
data-urls@7.0.0:
data-urls@8.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
whatwg-url: 17.1.2
transitivePeerDependencies:
- '@noble/hashes'
@@ -2484,27 +2501,27 @@ snapshots:
[email protected]: {}
[email protected].1:
[email protected].2:
dependencies:
'@asamuzakjp/css-color': 7.0.1
'@asamuzakjp/dom-selector': 9.2.1
'@asamuzakjp/css-color': 7.1.3
'@asamuzakjp/dom-selector': 9.2.4
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.14([email protected])
'@exodus/bytes': 1.15.1
'@csstools/css-syntax-patches-for-csstree': 1.1.15([email protected])
'@exodus/bytes': 1.16.0
css-tree: 3.2.1
data-urls: 7.0.0
data-urls: 8.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 7.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
lru-cache: 11.5.3
parse5: 8.0.1
saxes: 6.0.0
tough-cookie: 6.0.2
undici: 8.10.2
undici: 8.11.2
w3c-xmlserializer: 6.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 17.1.2
whatwg-url: 17.2.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
@@ -2513,7 +2530,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected]8.10:
[email protected]9.0:
dependencies:
commander: 15.0.0
@@ -2574,8 +2591,6 @@ snapshots:
devlop: 1.1.0
highlight.js: 11.11.2
[email protected]: {}
[email protected]: {}
[email protected]: {}
@@ -2982,11 +2997,11 @@ snapshots:
[email protected]: {}
[email protected]3.0: {}
[email protected]4.0: {}
[email protected]3.0:
[email protected]4.0:
dependencies:
playwright-core: 1.63.0
playwright-core: 1.64.0
[email protected]:
dependencies:
@@ -3182,6 +3197,10 @@ snapshots:
dependencies:
punycode: 2.3.1
[email protected]:
dependencies:
punycode: 2.3.1
[email protected]: {}
[email protected]: {}
@@ -3209,7 +3228,7 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
[email protected]0.2: {}
[email protected]1.2: {}
[email protected]:
dependencies:
@@ -3269,7 +3288,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected].2([email protected]):
[email protected].3([email protected]):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.7
@@ -3280,10 +3299,10 @@ snapshots:
esbuild: 0.28.2
fsevents: 2.3.3
[email protected]([email protected].1)([email protected].2([email protected])):
[email protected]([email protected].2)([email protected].3([email protected])):
dependencies:
'@types/chai': 5.2.3
'@vitest/mocker': 5.0.3([email protected].2([email protected]))
'@vitest/mocker': 5.0.3([email protected].3([email protected]))
chai: 6.2.2
es-module-lexer: 2.3.2
expect-type: 1.4.0
@@ -3294,10 +3313,10 @@ snapshots:
tinybench: 6.1.4
tinyexec: 1.3.0
tinyglobby: 0.2.17
vite: 8.3.2([email protected])
vite: 8.3.3([email protected])
why-is-node-running: 3.2.1
optionalDependencies:
jsdom: 30.1.1
jsdom: 30.1.2
transitivePeerDependencies:
- msw
@@ -3313,7 +3332,7 @@ snapshots:
[email protected]: {}
whatwg-url@16.0.1:
whatwg-url@17.1.2:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
@@ -3321,10 +3340,10 @@ snapshots:
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@17.1.2:
whatwg-url@17.2.0:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
'@exodus/bytes': 1.16.0
tr46: 7.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
+2 -2
View File
@@ -145,8 +145,8 @@ describe("what counts as an input a person performs", () => {
expect(c.roots.map((r) => r.kind)).toEqual(["dom-event"]);
});
it("leaves the environment's own events out", () => {
const c = only(`export function A() { window.addEventListener("resize", () => {}); return null; }`);
it.each(["resize", "pageshow"])("leaves the environment's %s event out", (event) => {
const c = only(`export function A() { window.addEventListener("${event}", () => {}); return null; }`);
expect(c.roots).toEqual([]);
expect(c.nonUser.map((n) => n.why)).toEqual(["RUNTIME_EVENT"]);
});
+21
View File
@@ -56,11 +56,13 @@ export const ACTIONS: UIAction[] = [
{ id: "chart.data", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.policy", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.rail", kind: "view", target: "none", proof: "browser" },
{ id: "chrome.more", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.theme", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.nav-rail", kind: "view", target: "none", proof: "interaction" },
{ id: "appearance.graphics", kind: "view", target: "none", proof: "interaction" },
{ id: "workspace.search", kind: "view", target: "none", proof: "interaction" },
{ id: "session.filter", kind: "view", target: "none", proof: "interaction" },
{ id: "session.more", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "chrome.settings", kind: "navigation", target: "none", proof: "interaction" },
{ id: "browser.open", kind: "view", target: "none", proof: "interaction" },
{ id: "browser.back", kind: "navigation", target: "none", proof: "interaction" },
@@ -70,7 +72,11 @@ export const ACTIONS: UIAction[] = [
{ id: "tool.copy-output", kind: "shell-native", target: "none", proof: "interaction" },
// The way out of a window whose assets an update replaced underneath it.
{ id: "chrome.reload", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.changed-files", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "chrome.fold-work", kind: "kernel-mutation", target: "none", proof: "interaction" },
{ id: "changes.open", kind: "navigation", target: "none", proof: "interaction" },
{ id: "chrome.receipt", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.receipt-line", kind: "view", target: "none", proof: "interaction" },
{ id: "chrome.keep-awake", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold", kind: "view", target: "none", proof: "interaction" },
{ id: "transcript.fold-preset", kind: "view", target: "none", proof: "interaction" },
@@ -244,6 +250,10 @@ export const ACTIONS: UIAction[] = [
{ id: "extensions.finish", kind: "navigation", target: "none", proof: "interaction" },
{ id: "extensions.add", kind: "navigation", target: "none", proof: "interaction" },
{ id: "extensions.pick-folder", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "folders.pick", kind: "shell-native", target: "none", proof: "interaction" },
{ id: "folders.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "folders.remove", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "folders.retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.export", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "extensions.enabled", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
{ id: "extensions.remove", kind: "destructive", target: "entity", proof: "authority-effect" },
@@ -357,6 +367,11 @@ export const ACTIONS: UIAction[] = [
{ id: "provider.model-check-all", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.model-check", kind: "repeatable", target: "entity", proof: "interaction" },
{ id: "provider.add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "provider.preset", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.preset-retry", kind: "repeatable", target: "none", proof: "interaction" },
{ id: "provider.preset-toggle", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.preset-back", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.preset-cancel", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.add-start", kind: "view", target: "none", proof: "interaction" },
{ id: "provider.select", kind: "view", target: "entity", proof: "interaction" },
{ id: "provider.move", kind: "view", target: "entity", proof: "interaction" },
@@ -415,6 +430,7 @@ export const ACTIONS: UIAction[] = [
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.feedback-reply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "notify.sound", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workspace.editor", kind: "repeatable", target: "none", proof: "authority-effect" },
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -460,6 +476,9 @@ export const ACTIONS: UIAction[] = [
{ id: "workspace.isolate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "reasoning.effort", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "workbench.new-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-files", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-changes", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.launch-browser", kind: "view", target: "none", proof: "interaction" },
{ id: "workbench.tab", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.close", kind: "view", target: "entity", proof: "interaction" },
{ id: "workbench.mode", kind: "view", target: "none", proof: "interaction" },
@@ -512,6 +531,8 @@ export const ACTIONS: UIAction[] = [
// The kernel announces the change on the stream, so no rebuild stands between
// the click and the canonical change.
{ id: "currency.mode", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "ask-timeout.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "ask-timeout.seconds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.pause", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.rounds", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
{ id: "progress-watch.token-multiple", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
@@ -523,7 +523,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
],
"replay": [
@@ -631,7 +632,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
]
},
@@ -693,7 +695,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
],
"replay": [
@@ -742,7 +745,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
]
},
@@ -896,7 +900,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
],
"replay": [
@@ -1025,7 +1030,8 @@
},
{
"kind": "turn_done",
"checkpointTurn": 0
"checkpointTurn": 0,
"workDurationMs": 1
}
]
}
+45 -4
View File
@@ -94,6 +94,7 @@ export const EN: Record<string, string> = {
"复制这段代码": "Copy this code",
"复制路径": "Copy path",
"思考 {chars}": "Thought · {chars}",
"耗时 {t}": "Worked for {t}",
"思考 {secs} 秒 · {chars}": "Thought for {secs}s · {chars}",
"{n} 字": "{n} chars",
"{n} 张图片": "{n} images",
@@ -293,7 +294,12 @@ export const EN: Record<string, string> = {
"新建会话": "Start a new session",
"空会话": "Empty session",
"{n} 轮": "{n} turns",
"还有 {n} 个 · 全部显示": "{n} more · show all",
"{n} / {total} 会话": "{n} / {total} sessions",
"显示更多(还有 {n} 个)": "Show more ({n} left)",
"还有 {n} 个较早的会话 · 用搜索查找": "{n} older conversations · use search to find them",
"还有 {n} 个较早的会话,在那台机器上查看": "{n} older conversations · see them on that machine",
"正在加载…": "Loading…",
"加载失败 · 重试": "Failed to load · retry",
"打开或新建项目…": "Open or start a project…",
"文件夹的完整路径": "Full path to the folder",
"尚无文件夹": "No folders yet",
@@ -522,7 +528,9 @@ export const EN: Record<string, string> = {
"凭据未通过,尚未确认": "Credentials were rejected; availability is still unknown",
"遇到限流,尚未确认": "Rate limited; availability is still unknown",
"网络异常,尚未确认": "Network error; availability is still unknown",
"地址跳转到了另一个主机,已拒绝,尚未确认": "The address redirected to another host and was refused; availability is still unknown",
"验证超时,尚未确认": "Verification timed out; availability is still unknown",
"本机服务没有在时限内回答,尚未确认;模型若刚启动可能还在加载,稍后再验证一次": "The local service did not answer in time, so this is unconfirmed. A model that was just started may still be loading; verify again shortly.",
"请求被拒绝,尚未确认": "Request rejected; availability is still unknown",
"尚未确认": "Availability is still unknown",
"读图": "Reads images",
@@ -537,6 +545,11 @@ export const EN: Record<string, string> = {
"记的是 {had},但它答的是 {got}。": "Recorded as {had}, but it answers as {got}.",
// 收工回执
"没有未经验证的部分": "nothing left unverified",
"等待超时,这个问题已被跳过": "Nobody answered in time; this question was skipped",
"有未验证的改动": "Unverified changes",
"这一轮自己说明了风险或未验证项": "This turn declared risks or unverified items",
"收起交付验收": "Collapse the delivery receipt",
"点击查看交付验收:改了什么、哪些没有验证": "Click to see the delivery receipt: what changed and what was not verified",
"未验证": "not verified",
"交付验收": "delivery receipt",
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
@@ -550,7 +563,9 @@ export const EN: Record<string, string> = {
"通知": "Notifications",
"只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、模型反过来问你、反馈有了回复,都可以让系统提醒一次。": "Only useful while you are not watching: a turn finishing, a stop for approval, the model asking you something, or a reply to your feedback can each raise one system alert.",
"发送系统通知": "Send system notifications",
"窗口在后台时由系统弹出提醒。立即生效,正在跑的会话下一次事件就按新设置来": "The system raises the alert while this window is in the background. Immediate: a running session follows the new setting from its next event.",
"默认关闭。开启后,无论窗口是否在前台都会由系统弹出提醒;声音由系统决定。立即生效,正在跑的会话下一次事件就按新设置来": "Off by default. When on, the system raises the alert whether or not this window is in front; the sound is whatever the system decides. Immediate: a running session follows the new setting from its next event.",
"播放提示音": "Play a sound",
"默认关闭。下面选中的情形出现时放一声短提示,不依赖系统通知,也不受系统通知静音的影响。窗口缩小、在后台时也会响": "Off by default. Plays a short chime for the occasions selected below, independent of system notifications and of their muting. It also sounds when the window is minimised or in the background.",
"这一轮结束时": "When the turn ends",
"回答写完、或者这一轮失败了": "The answer is written, or the turn failed",
"有操作等待批准时": "When something waits for approval",
@@ -999,8 +1014,10 @@ export const EN: Record<string, string> = {
"上一处": "Previous match",
"下一处": "Next match",
"关闭查找": "Close find",
"回合结束时给出回执": "End a turn with a receipt",
"列出这一轮改了什么、验了什么、哪些没有验;无话可说时不出现。下一轮起生效": "Lists what this turn changed, what it verified and what went unverified; absent when there is nothing to say. Takes effect from the next turn",
"交付验收卡片": "Delivery receipt card",
"每一轮结束后,主机会在会话末尾列出这一轮改了什么、验证了什么、哪些没有验证。记录始终保存,这里只决定是否显示。": "At the end of each turn the host lists what the turn changed, what it verified and what it did not. The record is always kept; this only decides whether it is shown.",
"显示交付验收卡片": "Show the delivery receipt card",
"默认不显示;打开后,当前会话和已有会话里的卡片都会出现。终端界面读同一项设置,下次启动时生效": "Off by default. Turning it on shows the cards in the current session and in earlier ones. The terminal reads the same setting and picks it up the next time it starts",
"任务运行时阻止系统休眠": "Keep the computer awake while tasks run",
"有会话正在运行时,电脑不会自动进入睡眠(电池供电时也一样);屏幕仍按系统设置熄灭,全部空闲后恢复。合上笔记本盖子不受此项控制": "While a session is running the computer will not go to sleep on its own, on battery power too; the screen still turns off as the system says, and normal sleep resumes once everything is idle. Closing a laptop lid is not controlled by this",
"改写这条消息": "Rewrite this message",
@@ -1036,6 +1053,9 @@ export const EN: Record<string, string> = {
"确定": "OK",
"重试": "Retry",
"设置": "Settings",
"还没有输出,命令仍在运行": "No output yet; the command is still running",
"已运行 {time}": "running for {time}",
"这条命令自带的超时上限 {n} 秒": "this command's own timeout is {n}s",
"专注": "Focus",
"退出专注": "Leave focus",
"专注:收起两侧栏": "Focus: put the side columns away",
@@ -1215,6 +1235,22 @@ export const EN: Record<string, string> = {
"分离状态": "detached",
"当前 Git 分支:{branch}": "Current Git branch: {branch}",
"{n} 个变更": "{n} changes",
"1 个文件已更改": "1 file changed",
"{n} 个文件已更改": "{n} files changed",
"{label},新增 {up} 行,删除 {down} 行,点击查看文件改动": "{label}, {up} lines added, {down} removed. Open file changes",
"{label},点击查看文件改动": "{label}. Open file changes",
"查看文件改动": "View file changes",
"文件改动": "File changes",
"在 Git 工作区里,输入框上方显示当前有多少文件已更改,点击查看。关闭后,数量回到分支标签旁。": "In a Git workspace, show how many files have changed above the input box; click to view them. When off, the count stays beside the branch chip.",
"在输入框上方显示文件改动": "Show file changes above the input box",
"折叠中间过程": "Fold the work in between",
"一轮结束后,把工具调用、思考和中间的文字收成一行,只留最后一句话;点那一行可以展开。运行中的一轮始终展开。": "When a turn ends, tool calls, thinking and the sentences in between are gathered into one line and only the last sentence stays out; click the line to open it. The turn that is running stays open.",
"折叠一轮里的中间过程": "Fold the work inside a turn",
"默认关闭;出错的步骤会在那一行上标出来": "Off by default; a step that failed is marked on that line",
"中间过程": "Work in between",
"思考与中间文字": "Thinking and notes",
"文字 {count}": "Notes: {count}",
"默认开启;数量来自 Git 工作区,与本轮对话是否改过文件无关": "On by default. The count comes from the Git work tree, whether or not this conversation edited the files",
"当前工作区 · {n} 个本地变更": "Current workspace · {n} local changes",
"复制回复": "Copy response",
"快速": "Fast",
@@ -1229,6 +1265,8 @@ export const EN: Record<string, string> = {
"正文行宽": "Body line width",
"适宜阅读": "Reading",
"铺满窗口": "Full width",
"连输入框一起铺满": "Full width, composer too",
"正文和输入框都跟随窗口宽度;输入框很宽时一行不易回到行首": "Prose and the composer both follow the window; a very wide composer makes a line harder to find the start of",
"每行长度控制在一眼能回到行首的范围": "Keeps a line short enough that your eye finds the next one",
"正文跟随窗口宽度,宽屏上不留两侧空白": "Prose follows the window, with nothing left empty at the sides",
"行宽只管正文:代码、命令与工具输出始终占满可用宽度。": "Line width applies to prose only — code, commands and tool output always use the width available.",
@@ -1395,10 +1433,13 @@ export const EN: Record<string, string> = {
"后台任务已结束:{name}": "Background job finished: {name}",
"后台任务已终止:{name}": "Background job killed: {name}",
"后台任务 {name} 失败,需要处理": "Background job {name} failed: needs attention",
"被信号 {signal} 终止": "Ended by signal {signal}",
"退出码 {code}": "Exit code {code}",
"已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”": "Recovered {n} unfinished instruction(s). Sending is paused: review them in the queue above the input box, then press Release",
"待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送": "Sending is paused. This message is queued and goes out once you press Release",
"引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:": "Guidance was not applied because the turn ended before it could be processed. Send it again if it is still needed:",
"费用显示币种已设为 {mode}": "Cost display currency set to {mode}",
"配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个": "The question wait time in the config file is not valid (it must be a whole number, 0 or 10 to 86400), so questions wait for you as long as it takes; set it again in Settings.",
"模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型": "The model is stuck repeating the same text; this turn was stopped. Try again, add guidance, or switch provider/model.",
"扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展": "The companion background program of extension {ext} is not running, so the extension was skipped this time (at {point}). Open Tools and integrations to check and start it, or disable the extension.",
"有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)": "{n} permission rule(s) name no tool, so they match no call and restrict nothing (for example {rule} in {list}). Delete or rewrite them under Settings > Permissions; a shell command is written Bash(command:*).",
@@ -18,6 +18,7 @@ export const EN_COMPOSER: Record<string, string> = {
"正在停止…": "Stopping…",
"正在发送…": "Sending…",
"描述任务、问题或要改的内容…": "Describe a task, question, or change…",
"描述任务…": "Describe a task…",
"任务输入": "Task input",
"命令与技能": "Commands and skills",
"正在加载命令与技能…": "Loading commands and skills…",
@@ -61,7 +62,7 @@ export const EN_COMPOSER: Record<string, string> = {
"工作区外的文件引用不到 · 这里放不下": "A file outside the workspace cannot be referenced — this drop has nowhere to go",
"图片": "image",
"图片不可用": "Image unavailable",
"文件": "file",
"文件": "Files",
"粘贴的文本": "pasted text",
"{n} 行 · 展开到输入框": "{n} lines · put it back in the box",
"添加附件": "Add an attachment",
@@ -3,6 +3,7 @@
// code adds one here. i18n.test.ts is what stops one from being forgotten.
export const EN_KERNEL: Record<string, string> = {
"请先打开一个本机会话,再读取本机设置": "Open a local session to read this machine’s settings",
"该路径指向网络上的另一台机器,不在当前工作区内。文件工具只按路径写法拒绝它,不会去访问;工作区本身在网络共享上时,其下的路径可用": "That path names another machine on the network and is outside this workspace. File tools refuse it by its spelling and never look it up; paths below a workspace that is itself on a network share are allowed",
"内置浏览器不会打开指向网络上另一台机器的路径,只按路径写法拒绝,不会去访问": "The built-in browser does not open a path that names another machine on the network; it is refused by its spelling and never looked up",
"内核繁忙,这次操作可能没有被收到,重试前请先确认": "The kernel is busy; the action may not have been received - check before retrying",
@@ -86,6 +87,14 @@ export const EN_KERNEL: Record<string, string> = {
"未能导出该插件:{detail}": "That plugin could not be exported: {detail}",
"无法解析本次安装请求:{detail}": "That install request could not be read: {detail}",
"安装失败:{detail}": "It could not be installed: {detail}",
"无法读取这个来源:{detail}": "That source could not be read: {detail}",
"这个来源里没有可安装的内容:{detail}": "There is nothing installable in this source: {detail}",
"来源里的清单没有通过校验:{detail}": "The manifest in the source did not validate: {detail}",
"来源与所选类型不符:{detail}": "The source does not match the chosen kind: {detail}",
"这个插件没有 Reasonix 能运行的能力:{detail}": "This plugin has no capability Reasonix can run: {detail}",
"来源要求先登录或授权:{detail}": "The source requires signing in or authorisation first: {detail}",
"找不到运行它所需的命令或运行时:{detail}": "The command or runtime it needs was not found: {detail}",
"目标已经存在:{detail}": "The target already exists: {detail}",
"这不是有效的社区包名称": "That is not a community package name",
"社区市场中没有这个已审核的包": "The community market has no approved package by that name",
"无法连接社区市场:{detail}": "The community market could not be reached: {detail}",
@@ -120,6 +129,18 @@ export const EN_KERNEL: Record<string, string> = {
"本机的 sandbox-exec 不可用,命令将不受限制地运行": "sandbox-exec is unusable on this machine, so commands run unconfined",
"Windows 上尚无操作系统级沙箱,命令将不受限制地运行": "Windows has no OS-level sandbox here yet, so commands run unconfined",
"该平台尚无可用的沙箱后端,命令将不受限制地运行": "This platform has no usable sandbox backend yet, so commands run unconfined",
"当前还有任务在运行,结束后才能更改工作区文件夹": "Finish the current work before changing the workspace folders.",
"这台服务器没有开启工作区文件夹编辑": "Workspace folder editing is not enabled on this server.",
"请填写文件夹路径": "A folder path is required.",
"没有可用的当前模型,无法应用文件夹更改": "There is no current model to apply the folder change on.",
"需要一个已存在的文件夹的绝对路径": "Use the absolute path of a folder that exists.",
"这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
"这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
"这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
"这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
"这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
"没有这个预设": "No curated preset has this id.",
"这个预设会创建多个来源,名称由预设决定": "This preset creates several sources and keeps its own names.",
"本机没有操作系统沙箱,「关进沙箱」一档无法保存": "This machine has no OS sandbox, so the jailed mode cannot be saved",
"该文件夹仍有 {n} 个打开的面板,请先关闭再移除": "This folder still has {n} open panes — close them before removing it",
@@ -167,6 +188,7 @@ export const EN_KERNEL: Record<string, string> = {
"读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试": "The configuration could not be read, so there is no safe place to store the key; check the config and try again",
"请填写接口地址": "Enter an endpoint address",
"无法识别「{kind}」这种接入方式": "“{kind}” is not a protocol this knows",
"这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header": "This source pins a request URL or auth header written for its current protocol, so its protocol cannot be changed here: remove request_url, chat_url and auth_header from the config file first",
"请至少选择一个模型": "Pick at least one model",
"默认模型「{model}」不在已选择的模型中": "The default model “{model}” is not among the ones you picked",
"显示名称最多 {max} 个字符": "A display name can be at most {max} characters",
@@ -244,6 +266,8 @@ export const EN_KERNEL: Record<string, string> = {
"The provider errored (HTTP {status}) — nothing you typed is wrong; try again shortly",
"该地址在限定时间内没有响应。请检查网络或代理,或稍后重试":
"That address did not answer in time. Check the network or proxy, or try again shortly",
"该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}":
"That address redirected to {target} and was refused so the key is not sent elsewhere. If you trust {target}, change the address to {target}",
"检查失败,没有具体原因": "The check failed and gave no reason",
"无法连接该地址。请检查网络是否通畅,以及地址是否有误":
"Cannot reach that address. Check the network, or whether the address has a typo",
@@ -347,6 +371,7 @@ export const EN_KERNEL: Record<string, string> = {
"该问题已有其他答案": "That question already has a different answer",
"状态图标设置未能保存:{detail}": "The status icon settings were not saved: {detail}",
"内置浏览器的消息格式不正确,本次回传被丢弃": "The built-in browser sent malformed messages, and they were dropped",
"这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
@@ -375,10 +400,16 @@ export const EN_KERNEL: Record<string, string> = {
"无法读取工作区文件列表": "The workspace file list could not be read",
"该文件不是可编辑文本或超过大小限制": "The file is not editable text or exceeds the size limit",
"不支持这个币种,只能选自动、CNY 或 USD:{detail}": "That currency is not supported; choose Auto, CNY or USD: {detail}",
"运行时已更换,费用显示币种未保存,请重试": "The runtime changed; the display currency was not saved. Try again.",
"费用显示币种未能保存:{detail}": "The display currency was not saved: {detail}",
"文件改动显示设置未能保存:{detail}": "The file-changes display setting was not saved: {detail}",
"折叠中间过程的设置未能保存:{detail}": "The fold-the-work setting was not saved: {detail}",
"无进展设置未能保存:{detail}": "The progress settings were not saved: {detail}",
"提问等待时长未能保存:{detail}": "The question wait time was not saved: {detail}",
"等待时长超出允许范围,未做任何修改:{detail}": "That wait time is out of range; nothing was changed: {detail}",
"自动归档设置未能保存:{detail}": "The auto-archive settings were not saved: {detail}",
"天数超出允许范围,未做任何修改:{detail}": "The number of days is out of range; nothing was changed: {detail}",
"交付验收卡片的设置未能保存:{detail}": "The receipt card setting was not saved: {detail}",
"主目录或磁盘根目录不能整体信任,请打开具体的项目文件夹": "A home directory or drive root cannot be trusted as a whole - open a specific project folder",
"未能记下对此文件夹的信任决定:{detail}": "The trust decision for this folder was not saved: {detail}",
"该数值超出允许范围,未做任何修改:{detail}": "That value is out of range; nothing was changed: {detail}",
+48 -1
View File
@@ -70,6 +70,13 @@ export const EN_SETTINGS: Record<string, string> = {
"其他分区与当前会话不受影响;关闭设置后重新打开可重试。":
"Other sections and your session are unaffected; close Settings and reopen to try again.",
"操作未完成": "That did not go through",
"{name} 的删除没有完整完成": "Removing {name} did not fully complete",
"已删除 {name}": "Removed {name}",
"已改用 {to}:{roles}": "Now using {to} for: {roles}",
"已清空,对应功能已关闭:{roles}": "Cleared, so these are off: {roles}",
"恢复审查": "Recovery review",
"小型分类": "Small classifications",
"顾问": "Advisor",
// ── 设置:会话 ───────────────────────────────────────────────────
"决定任务完成的判定标准。切换立即生效,不会重建运行时。": "Sets what counts as finished. Switching takes effect immediately and does not rebuild the runtime.",
@@ -245,6 +252,20 @@ export const EN_SETTINGS: Record<string, string> = {
"移除 {path} 的写入权限": "Stop allowing writes to {path}",
"添加可写目录,例如 /tmp/scratch": "Open one more writable directory, e.g. /tmp/scratch",
"额外可写目录": "Also writable",
"此项目的额外文件夹": "Extra folders for this project",
"agent 默认只能修改当前项目的文件夹。在这里添加的文件夹也允许写入,只对本项目生效,记在你的用户目录里,不会写进仓库;上面的「额外可写目录」则对所有项目生效。":
"The agent can only modify the current project's folder by default. A folder added here is writable too. It applies to this project only and is kept in your user directory, never in the repository; \"Also writable\" above applies to every project.",
"「完全放行」只决定是否询问,不会扩大可写范围;要让 agent 修改另一个文件夹,请在这里添加。":
"\"Allow everything\" only decides whether to ask; it does not widen where writes can land. To let the agent change another folder, add it here.",
"项目文件夹": "Project folders",
"添加后,agent 将能修改并删除该文件夹里的任何文件。": "Once added, the agent can change and delete any file in that folder.",
"无法读取这个项目的额外文件夹": "Could not read this project's extra folders.",
"移除后,已经打开的窗格要等重建后才会收窄可写范围。": "After a removal, panes that are already open narrow their writable area only when they are rebuilt.",
"当前项目": "This project",
"移除文件夹 {path}": "Remove folder {path}",
"要添加的文件夹路径": "Folder path to add",
"绝对路径,例如 /Users/you/code/shared-lib": "Absolute path, for example /Users/you/code/shared-lib",
"还有 {n} 个窗格正在运行,它们会在下次重建时采用这个改动。": "{n} panes are still running and will take this change at their next rebuild.",
"实际可写": "Actually writable",
"命令执行方式": "How commands run",
"启用沙箱后,命令无法写入清单之外的位置。上方的可写目录清单由操作系统实施,不依赖 agent 自觉遵守。":
@@ -725,8 +746,24 @@ export const EN_SETTINGS: Record<string, string> = {
"模型可承载的输入、工具结果与输出总量。": "The total input, tool results and output the model can hold.",
"单轮生成上限;留空使用内核的模型默认值。": "The per-turn generation limit; leave blank to use the runtime's model default.",
"高级连接选项": "Advanced connection options",
"从常用服务开始": "Start from a common service",
"留空就用预设自带的名称;重名时内核会自动编号。": "Leave empty to use the preset's own name; the kernel numbers a duplicate.",
"模型目录、上下文窗口和思考设置由内核按这个服务的预设一并创建。": "The kernel creates the model catalogue, context window and thinking settings for this service from its preset.",
"没有匹配的服务": "No matching service",
"预设自带的名称": "The preset's own name",
"搜索常用服务": "Search services",
"内核没有提供常用服务预设,可以在下面手动填写。": "The kernel offers no service presets; fill the form in below.",
"常用服务列表没能读到:{detail}": "The list of common services could not be read: {detail}",
"思考控制、代理与中转站自定义参数": "Thinking controls, proxy and gateway parameters",
"可选": "Optional",
"必填": "Required",
"日志与异常退出记录": "Logs and abnormal-exit records",
"出现问题或闪退后,反馈时附上这个目录里的文件会更容易查清原因;这里只显示位置,不会上传任何内容。": "After a problem or a crash, attaching the files in this folder to a report makes the cause easier to find. This only shows the location; nothing is uploaded.",
"还有更早的记录没有列出,请在上面的文件夹里查看。": "Older records are not listed; look in the folder above.",
"复制日志目录": "Copy the log folder",
"没有留下异常退出的记录。": "No abnormal-exit record has been left.",
"内核异常退出时留下的记录,位于上面目录里的 crash 文件夹:": "Records left when the kernel exited abnormally, in the crash folder inside the directory above:",
"还有 {n} 项": "{n} more",
"发送思考控制": "Send thinking controls",
"允许当前协议发送思考控制字段;模型是否真的思考仍由模型决定。": "Allow this protocol to send its thinking-control fields. The model still decides whether it actually reasons.",
"当前协议没有思考控制字段,切换模型时仍可在推理强度菜单查看支持情况。": "This protocol has no thinking-control field. Model support is still shown in the reasoning-effort menu.",
@@ -938,7 +975,17 @@ export const EN_SETTINGS: Record<string, string> = {
"输入 token 上限": "Input token backstop",
"与模型无关的后备:自上次有进展以来的累计输入达到上下文窗口的这个倍数时同样提示。默认 {n} 倍。": "A model-independent backstop: the same notice once the input spent since the last progress reaches this many context windows. Default {n}×.",
"倍窗口": "× window",
"主题包过大,请控制在 {limit} MB 以内": "The theme pack is too large; keep it within {limit} MB.",
"请输入 1 到 {n} 之间的整数。": "Enter a whole number from 1 to {n}.",
"提问等待超时": "Question wait time",
"长任务中途遇到提问时,不必一直停在那里等人。": "A long task need not stop at a question and wait for someone.",
"提问超时后自动跳过": "Skip a question nobody answers",
"默认关闭:提问一直等你回答。开启后,超过下面的时间没有回答,这个问题会被跳过,任务继续;模型会被告知这是超时而不是你的回答,并按自己的假设往下做(全自动或目标模式下尤其如此)。终端界面读同一项设置,用 /ask-timeout 修改。": "Off by default: a question waits for you. When on, a question left unanswered for the time below is skipped and the task goes on; the model is told it was a timeout, not your answer, and carries on under its own assumption (especially in full-auto or goal runs). The terminal reads the same setting; change it there with /ask-timeout.",
"等待时长": "Wait time",
"配置文件里的提问等待时长无效(须为 0 或 {min} 到 {max} 之间),现在按“一直等待”处理:{path}": "The question wait in the config file is not valid (it must be 0 or between {min} and {max}), so questions wait as long as it takes: {path}",
"从内核发出问题时开始计时。默认 {n} 秒。": "Counted from when the kernel asks the question. Default {n} seconds.",
"请输入 {min} 到 {max} 之间的整数。": "Enter a whole number from {min} to {max}.",
"无法读取提问等待设置。": "The question wait setting could not be read.",
"无法读取无进展设置。": "Could not read the progress settings.",
"自动归档不活跃的对话": "Auto-archive inactive conversations",
"归档只是把对话移出常用列表,内容不动,随时可在“已归档”里恢复。置顶的、正在运行的、等待你回答或批准的对话不会被归档。只处理已打开会话窗格的文件夹;导入的旧会话按原有的最后活动时间计算,开启后可能一次归档较多。": "Archiving only moves a conversation out of the everyday list; nothing is deleted and you can restore it from Archived at any time. Pinned conversations, running ones and ones waiting for your answer or approval are never archived. Only folders with an open session pane are swept, and imported older conversations count from their original last activity, so enabling this can archive many at once.",
@@ -951,7 +998,7 @@ export const EN_SETTINGS: Record<string, string> = {
"已归档": "Archived",
"已自动归档 {n} 个会话,可在“已归档”中恢复。": "{n} conversations were archived automatically. You can restore them from Archived.",
"图标栏": "Icon rail",
"会话侧栏左边的一列图标,直达用量、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens usage, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
"会话侧栏左边的一列图标,直达会话、用量、文件、工具、扩展、记忆、远程、反馈、账号和设置。窗口窄到手机宽度时不显示。": "A column of icons left of the session sidebar that opens conversation, usage, files, tools, extensions, memory, remote, feedback, account and settings. It is not drawn when the window is as narrow as a phone.",
"始终显示": "Always",
"仅侧栏收起时显示": "Only when the sidebar is collapsed",
"不显示": "Never",
@@ -24,6 +24,8 @@ export const EN_TRAJECTORY: Record<string, string> = {
"系统": "System",
"信号轨": "Signal rail",
"与下方共用时间轴": "Shares the timeline below",
"没有时长、也不是工具调用的运行记录(最多前 8 条),按发生的时刻摆在时间轴上,所以位置就是时间,无法拖动;点一个看它的记录。":
"Records of the run that have no duration and are not tool calls (the first 8 at most), placed on the timeline at the moment they happened. Their position is their time, so they cannot be dragged; click one to see its record.",
"活动回合": "Activity rounds",
"点击一行查看记录": "Select a row to inspect it",
"模型回合": "Model round",
+1 -1
View File
@@ -95,7 +95,7 @@ export const EN_WINDOW: Record<string, string> = {
"Reasonix 内置 Browser": "Reasonix built-in Browser",
"部分网站可能禁止嵌入;此时可在外部浏览器打开。": "Some sites block embedding; open them in your external browser instead.",
"正在打开网页…": "Opening page…",
"关闭内置 Browser": "Close the built-in Browser",
"收起工作台": "Collapse the workbench",
"打开内置 Browser": "Open the built-in Browser",
"在上方地址栏输入网址,即可在当前任务旁浏览资料。": "Enter an address above to browse beside the current task.",
"浏览内容不会替换或中断当前会话。部分网站禁止嵌入时,可使用右上角的外部打开。": "Browsing will not replace or interrupt the current task. If a site blocks embedding, use Open externally in the toolbar.",
+9
View File
@@ -43,6 +43,15 @@ export function tokens(n: number): string {
return `${decimals(n / 1_000_000, 1)}M`;
}
/** elapsed is a run time said compactly: seconds under a minute, then minutes
* and hours, a zero unit left out. It matches the terminal's wording. */
export function elapsed(ms: number): string {
const total = Math.round(ms / 1000);
if (total < 60) return `${total}s`;
const parts = [[Math.floor(total / 3600), "h"], [Math.floor((total % 3600) / 60), "m"], [total % 60, "s"]] as const;
return parts.filter(([n]) => n > 0).map(([n, u]) => `${n}${u}`).join(" ");
}
/** seconds spells a millisecond duration. One place by default — the callers
* that used to pick their own each picked a different one. */
export const seconds = (ms: number, digits = 1) => `${decimals(ms / 1000, digits)}s`;
+8 -1
View File
@@ -1,3 +1,5 @@
import { t } from ".";
export const JOB_FINISHED = "job_finished";
export const JOB_KILLED = "job_killed";
export const JOB_FAILED = "job_failed";
@@ -16,10 +18,15 @@ export function jobNoticeVars(detail: string | undefined): { name: string } | un
}
}
// How a failed process ended, worded from the typed fields the producer wrote:
// a signal first, since an exit code is then only the shell's echo of it. The
// job function's own error text stands in when the producer did not type it.
export function jobErrorText(detail: string | undefined): string | undefined {
if (!detail) return undefined;
try {
const p = JSON.parse(detail) as { error?: unknown };
const p = JSON.parse(detail) as { error?: unknown; exitCode?: unknown; signal?: unknown };
if (typeof p.signal === "number" && p.signal > 0) return t("被信号 {signal} 终止", { signal: p.signal });
if (typeof p.exitCode === "number") return t("退出码 {code}", { code: p.exitCode });
return typeof p.error === "string" && p.error !== "" ? p.error : undefined;
} catch {
return undefined;
@@ -24,6 +24,12 @@ const coded = (message: string, code: string, params?: Record<string, string | n
// upstream of this — dotted codes, sentinels, typed families — buys nothing if
// the last hop prints the English that rode along for the log.
describe("what a reader is told a refusal was", () => {
it("asks for a currency retry when its runtime owner changed", () => {
const refusal = coded("fixture fallback", "display_currency.runtime_changed");
expect(reason(refusal)).toBe("运行时已更换,费用显示币种未保存,请重试");
localStorage.setItem(STORAGE, "en"); boot();
expect(reason(refusal)).toBe("The runtime changed; the display currency was not saved. Try again.");
});
it("renders shell refusal causes in Chinese and English", () => {
for (const code of ["shell.destructive_target", "shell.analysis_unknown", "shell.delete_sequence", "shell.delete_nonliteral", "shell.delete_option", "shell.syntax_error", "shell.parser_unavailable", "shell.parser_timeout", "shell.command_line_too_long"]) {
localStorage.setItem(STORAGE, "zh"); boot();
+30
View File
@@ -60,6 +60,7 @@ const SAID: Record<string, string> = {
"provider.name_taken": "已经有名为「{name}」的连接了,换一个名称",
"provider.config_unreadable": "读不到配置文件,没法安全地选择密钥存放位置,请检查配置后重试",
"provider.endpoint_required": "请填写接口地址",
"provider.kind_pinned_endpoint": "这个来源固定了为当前协议写的请求地址或认证方式,不能直接换协议:请先在配置文件里去掉 request_url、chat_url 和 auth_header",
"provider.kind_unsupported": "无法识别「{kind}」这种接入方式",
"provider.no_models_picked": "请至少选择一个模型",
"provider.default_not_selected": "默认模型「{model}」不在已选择的模型中",
@@ -221,6 +222,7 @@ const SAID: Record<string, string> = {
"provider.probe.upstream_error": "服务商返回错误(HTTP {status}),与填写内容无关,请稍后重试",
"provider.probe.timeout": "该地址在限定时间内没有响应。请检查网络或代理,或稍后重试",
"provider.probe.unreachable": "无法连接该地址。请检查网络是否通畅,以及地址是否有误",
"provider.probe.redirect_refused": "该地址跳转到了 {target},已拒绝,以免 key 被发到别处。如果信任 {target},请把服务地址改成 {target}",
"provider.probe.failed": "检查失败,没有具体原因",
"provider.probe.decision_path_not_found": "地址可以连通,但没有 System One 决策接口。请填写服务的基地址,不要带 /v1/systemone,Reasonix 会自己在后面追加",
"provider.probe.decision_not_compatible": "该地址有响应,但返回的不是 System One 的决策结果。请确认这是决策协议服务的基地址",
@@ -278,6 +280,7 @@ const SAID: Record<string, string> = {
"session.bind_failed": "接管该会话失败,请重新打开窗口",
"session.has_open_pane": "该会话仍有打开的面板,请先关闭",
"session.outside_workspace": "该路径不属于任何已知的工作区",
"settings.local_runtime_required": "请先打开一个本机会话,再读取本机设置",
"hub.no_runtime_open": "尚未打开任何会话",
// ── 远程 ─────────────────────────────────────────────────────────
@@ -350,7 +353,13 @@ const SAID: Record<string, string> = {
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
"changed_files.save_failed": "文件改动显示设置未能保存:{detail}",
"fold_work.save_failed": "折叠中间过程的设置未能保存:{detail}",
"ask_timeout.save_failed": "提问等待时长未能保存:{detail}",
"ask_timeout.out_of_range": "等待时长超出允许范围,未做任何修改:{detail}",
"receipt.save_failed": "交付验收卡片的设置未能保存:{detail}",
"progress_watch.save_failed": "无进展设置未能保存:{detail}",
"auto_archive.save_failed": "自动归档设置未能保存:{detail}",
"auto_archive.out_of_range": "天数超出允许范围,未做任何修改:{detail}",
@@ -372,6 +381,14 @@ const SAID: Record<string, string> = {
"plugin.export_failed": "未能导出该插件:{detail}",
"install.request_unreadable": "无法解析本次安装请求:{detail}",
"install.failed": "安装失败:{detail}",
"install.source_unreadable": "无法读取这个来源:{detail}",
"install.manifest_missing": "这个来源里没有可安装的内容:{detail}",
"install.manifest_invalid": "来源里的清单没有通过校验:{detail}",
"install.unsupported_kind": "来源与所选类型不符:{detail}",
"install.no_compatible_capabilities": "这个插件没有 Reasonix 能运行的能力:{detail}",
"install.auth_required": "来源要求先登录或授权:{detail}",
"install.binary_missing": "找不到运行它所需的命令或运行时:{detail}",
"install.already_exists": "目标已经存在:{detail}",
"install.bad_answer": "无法解析安装器的响应:{detail}",
"market.bad_slug": "这不是有效的社区包名称",
"market.not_found": "社区市场中没有这个已审核的包",
@@ -406,6 +423,18 @@ const SAID: Record<string, string> = {
"sandbox.no_sandbox_exec": "本机的 sandbox-exec 不可用,命令将不受限制地运行",
"sandbox.unsupported_on_windows": "Windows 上尚无操作系统级沙箱,命令将不受限制地运行",
"sandbox.unsupported_platform": "该平台尚无可用的沙箱后端,命令将不受限制地运行",
"workspace_roots.busy": "当前还有任务在运行,结束后才能更改工作区文件夹",
"workspace_roots.editing_disabled": "这台服务器没有开启工作区文件夹编辑",
"workspace_roots.path_required": "请填写文件夹路径",
"workspace_roots.rebuild_unavailable": "没有可用的当前模型,无法应用文件夹更改",
"workspace_roots.not_directory": "需要一个已存在的文件夹的绝对路径",
"workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
"workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
"workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
"workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
"workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
"provider.preset_unknown": "没有这个预设",
"provider.preset_named_entries": "这个预设会创建多个来源,名称由预设决定",
"sandbox.unavailable": "本机没有操作系统沙箱,「关进沙箱」一档无法保存",
"remote.install_disabled": "{host} 上没有 reasonix,且本机已设置为不自动安装。请将安装方式改回「自动」,或自行在该机器上安装",
"remote.npm_unavailable": "{host} 上无法运行 npm —— 通常是该机器未安装 Node.js。请安装 Node.js,或将安装方式改为「上传」",
@@ -447,6 +476,7 @@ const SAID: Record<string, string> = {
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
"studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
"studio.pin_rejected": "版本固定未能保存:{detail}",
"studio.notes_bad_version": "这不是一个已发布的版本号",
"studio.notes_absent": "这个版本没有发布更新内容",
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { NOTICE_TEXT } from "./notices";
import { NOTICE_VAR_CODES, leftoverDetail, noticeWording } from "./notice_wording";
const placeholders = (s: string) => [...s.matchAll(/\{(\w+)\}/g)].map((m) => m[1]);
const PAYLOAD: Record<string, string | undefined> = {
extension_skipped: JSON.stringify({ extension: "e", point: "tool.before" }),
inbox_recovered: JSON.stringify({ count: 2 }),
permission_rules_dormant: JSON.stringify({ rules: [{ list: "ask", rule: "Foo(bar)" }] }),
job_finished: JSON.stringify({ id: "j", label: "build" }),
job_killed: JSON.stringify({ id: "j", label: "build" }),
job_failed: JSON.stringify({ id: "j", label: "build" }),
display_currency: "USD",
compact_declined: undefined,
compact_failed: "summary_failed",
compact_held: "summary_failed",
};
describe("notice wording", () => {
const withSlots = Object.entries(NOTICE_TEXT).filter(([, s]) => placeholders(s).length > 0);
it.each(withSlots)("%s registers a reader for every placeholder it has", (code, sentence) => {
expect(NOTICE_VAR_CODES, `${code} has placeholders but no variable reader in notice_wording.ts`).toContain(code);
expect(code in PAYLOAD, `add a sample payload for ${code}`).toBe(true);
const w = noticeWording(code, PAYLOAD[code]);
expect(w).toBeDefined();
for (const key of placeholders(sentence)) expect(w?.vars).toHaveProperty(key);
});
it.each(NOTICE_VAR_CODES)("%s yields nothing, never a half-filled sentence, for an unreadable detail", (code) => {
if (code === "display_currency" || code === "compact_declined") return;
expect(noticeWording(code, "not json")).toBeUndefined();
});
it("words a placeholder-free code without variables", () => {
expect(noticeWording("verification_stalled", "anything")?.vars).toBeUndefined();
});
it("has no wording for an unknown code", () => {
expect(noticeWording("some_future_code", "x")).toBeUndefined();
expect(noticeWording(undefined, "x")).toBeUndefined();
});
it.each([
["extension_skipped", '{"extension":"","point":"p"}'],
["extension_skipped", '{"extension":3,"point":"p"}'],
["inbox_recovered", '{"count":"2"}'],
["inbox_recovered", '{"count":0}'],
["inbox_recovered", '{"count":1.5}'],
["permission_rules_dormant", '{"rules":[]}'],
["permission_rules_dormant", '{"rules":[{"list":"ask"}]}'],
["permission_rules_dormant", '{"rules":[{"list":1,"rule":"x"}]}'],
["job_finished", '{"label":"build"}'],
["job_killed", '{"id":""}'],
["job_failed", '{"id":5}'],
["compact_failed", "no_such_reason"],
])("%s reads valid JSON of the wrong shape (%s) as no payload", (code, detail) => {
expect(noticeWording(code, detail)).toBeUndefined();
});
it("keeps the diagnostic only where the detail was not read as a payload", () => {
expect(leftoverDetail("job_failed", '{"id":"j","error":"exit 2"}', { name: "j" })).toBe("exit 2");
expect(leftoverDetail("job_finished", '{"id":"j"}', { name: "j" })).toBeUndefined();
expect(leftoverDetail("compact_failed", "future_code", undefined)).toBe("future_code");
});
});
@@ -0,0 +1,50 @@
import { t } from "./index";
import { FOLD_WHY, NO_CODE_WHY } from "./compaction_why";
import { EXTENSION_SKIPPED, extensionSkippedVars } from "./extension_skipped";
import { INBOX_RECOVERED, inboxRecoveredVars } from "./inbox_recovered";
import { JOB_FAILED, JOB_FINISHED, JOB_KILLED, jobErrorText, jobNoticeVars } from "./job_notice";
import { NOTICE_TEXT } from "./notices";
import { PERMISSION_RULES_DORMANT, permissionRulesDormantVars } from "./permission_rules_dormant";
export type NoticeVars = Record<string, string | number>;
const compactReason = (code: string) => (detail: string | undefined): NoticeVars | undefined => {
const why = detail ? FOLD_WHY[detail] : code === "compact_declined" ? NO_CODE_WHY : undefined;
return why ? { why: t(why) } : undefined;
};
// Every code whose sentence has a placeholder reads its values from the detail
// here; a placeholder code missing from this table fails notice_wording.test.ts.
const VARS: Record<string, (detail: string | undefined) => NoticeVars | undefined> = {
[EXTENSION_SKIPPED]: extensionSkippedVars,
[INBOX_RECOVERED]: inboxRecoveredVars,
[PERMISSION_RULES_DORMANT]: permissionRulesDormantVars,
[JOB_FINISHED]: jobNoticeVars,
[JOB_KILLED]: jobNoticeVars,
[JOB_FAILED]: jobNoticeVars,
display_currency: (detail) => ({ mode: detail || "auto" }),
compact_declined: compactReason("compact_declined"),
compact_failed: compactReason("compact_failed"),
compact_held: compactReason("compact_held"),
};
export const NOTICE_VAR_CODES: readonly string[] = Object.keys(VARS);
// The sentence for a kernel code in this window's language, with the values its
// placeholders need. Undefined when the code has no sentence or its detail does
// not carry the values, so the caller keeps the kernel's own English.
export function noticeWording(code: string | undefined, detail: string | undefined): { text: string; vars?: NoticeVars } | undefined {
const text = code ? NOTICE_TEXT[code] : undefined;
if (!code || !text) return undefined;
const read = VARS[code];
if (!read) return /\{\w+\}/.test(text) ? undefined : { text };
const vars = read(detail);
return vars ? { text, vars } : undefined;
}
// What of the detail is still worth drawing under the sentence: all of it when
// nothing was read from it, only the job's error text when it was a payload.
export function leftoverDetail(code: string | undefined, detail: string | undefined, vars: NoticeVars | undefined): string | undefined {
if (!vars) return detail;
return code === JOB_FAILED ? jobErrorText(detail) : undefined;
}
@@ -25,6 +25,7 @@ export const NOTICE_TEXT: Record<string, string> = {
memory_migration_backup: "旧版记忆文件没能先备份,所以没有迁移,保持原样;详情里有具体原因",
inbox_recovered: "已恢复 {n} 条未完成的指令。待发送已暂停,请先在输入框上方的队列里查看,再点“继续派发”",
queue_paused_hold: "待发送已暂停,这条消息已排入队列,点“继续派发”后才会发送",
input_refused: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发",
unapplied_steer: "引导没有生效:这一轮在处理它之前就结束了。如果仍然需要,请再发送一次:",
display_currency: "费用显示币种已设为 {mode}",
job_finished: "后台任务已结束:{name}",
@@ -36,5 +37,6 @@ export const NOTICE_TEXT: Record<string, string> = {
compact_held: "自动压缩暂缓,上次尝试没有完成:{why}",
permission_rules_dormant: "有 {n} 条权限规则没有对应的工具,匹配不到任何调用,因此起不到限制作用(如「{list}」里的 {rule});到「设置 → 权限」里删除或改写,命令要写成 Bash(命令:*)",
extension_skipped: "扩展 {ext} 的配套后台程序没有运行,该扩展本次(在 {point})已被跳过;到「工具与集成」里查看并启动它,或停用该扩展",
ask_timeout_invalid: "配置文件里的提问等待时长无效(须为 0 或 10 到 86400 之间的整数),提问现在会一直等你回答;到设置里重新设一个",
perseveration_loop: "模型卡在重复输出同一段文字,这一轮已停止;可以重试、补充引导,或换一个供应商/模型",
};
+9
View File
@@ -5,12 +5,21 @@ import { reason } from "./i18n/kernel";
import { track as trackWidth } from "./ui/viewport";
import "./styles/tokens.css";
import "./styles/app.css";
import "./styles/dock.css";
import "./styles/studio.css";
import "./styles/tips.css";
import "./styles/waitnote.css";
import "./styles/narrow.css";
import "./styles/workfold.css";
import "./styles/workbench-launcher.css";
import "./styles/feedback.css";
import "./styles/community.css";
import "./styles/sponsor.css";
import "./styles/chart.css";
import "./styles/versions.css";
import "./styles/changedpill.css";
import "./styles/sessionnotes.css";
import "./styles/providerpresets.css";
import { App } from "./ui/App";
import { SseHub } from "./port/hub";
import { HttpError } from "./port/http_error";
+1 -1
View File
@@ -15,7 +15,7 @@ const SOURCES = import.meta.glob(["./**/*.{ts,tsx}", "../perf/**/*.{ts,tsx}", "!
// Entry points are reached by the bundler, not by an import. Declared, never
// inferred — nothing in a filename says whether something mounts it.
const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx"]);
const ENTRIES = new Set(["src/main.tsx", "perf/harness.tsx", "src/testsetup.ts"]);
/** One spelling for every file, rooted at the project rather than at whichever
* directory the glob happened to resolve it from. */
@@ -9,7 +9,7 @@
// `state` comes from the kernel because "interrupted" appears in no record: it
// is what an open barrier means once nothing is waiting on it, and only the
// host knows whether anything still is.
export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "superseded";
export type AdjudicationState = "interrupted" | "resolved" | "cancelled" | "timed_out" | "superseded";
export interface AdjudicationEntry {
barrier_id: string;
+49 -1
View File
@@ -11,6 +11,12 @@ export interface PermissionLists {
deny: string[];
}
export interface DormantPermissionRule {
list: string;
rule: string;
tool: string;
}
// Editable user lists and separate project rules that are in force. shadowedBy
// names a project config that outranks the edited file; effective is the
// merged boundary the controller actually loaded.
@@ -25,7 +31,7 @@ export interface PermissionRules extends PermissionLists {
shadowedBy?: string;
effective?: PermissionLists;
// Saved rules that name no tool, so they match nothing.
dormant?: { list: string; rule: string; tool: string }[];
dormant?: DormantPermissionRule[];
}
// Where an approved write may land, and whether bash runs jailed. The
@@ -55,6 +61,15 @@ export interface SandboxSettings {
shadowedBy?: string;
}
// The project's primary folder and the extra folders the user granted it write
// access to. pending counts other panes on the project that were busy and take
// the change at their next rebuild.
export interface WorkspaceRoots {
primary: string;
extra: string[];
pending?: number;
}
// What a settings surface shows in place of a save it cannot perform. The
// kernel refuses every write while the file will not parse, so this arrives
// before anything is tried rather than as each panel's own error.
@@ -99,8 +114,41 @@ export interface DisplayCurrencySettings {
path: string;
}
// Whether the changed-files pill is drawn above the composer; the user file
// holds it.
// Whether Studio folds a turn's intermediate work under one line; the user file
// holds it.
export interface FoldWorkSettings {
fold: boolean;
path: string;
}
export interface ChangedFilesSettings {
show: boolean;
path: string;
}
// When a run reads as no longer moving. Only the user file holds it: a
// project file cannot pause the user's runs.
// How long an ask question waits before the host skips it; 0 waits. The user
// file holds it, so the terminal reads the same answer.
export interface AskTimeoutSettings {
seconds: number;
// The kernel's code for a value the file holds that cannot be applied.
invalid?: string;
defaultSeconds: number;
minSeconds: number;
maxSeconds: number;
path: string;
}
// Whether the end-of-turn delivery receipt is drawn as a card; the user file
// holds it, so the terminal reads the same answer.
export interface ReceiptSettings {
show: boolean;
path: string;
}
export interface ProgressWatchSettings {
pause: boolean;
rounds: number;
Loaded 100 of 819 files, more files were not shown because too many files have changed in this diff. Show more