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 f1323bb2fa Merge pull request #12610 from esengine/docs/studio-2.35.0-notes
docs(release): Studio 2.35.0 notes
2026-10-10 17:51:37 -07:00
yhh 37a4e6e4ba docs(release): Studio 2.35.0 notes 2026-10-10 17:38:43 -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 0b016ab9e5 Merge pull request #12597 from esengine/feat/mcp-pin-tool-definitions
feat(plugin): pin MCP tool definitions at approval and hold changed tools
2026-10-10 15:56:08 -07:00
YHH 733091b9ca Merge pull request #12609 from esengine/fix/trusted-store-growth
fix(agent): stop copying the contract's criteria into every sealed bundle
2026-10-10 15:04:02 -07:00
YHH a3ab566e7f Merge pull request #12112 from KHG420/codex/studio-directory-copy-root-alias
fix(installsource): copy directory root aliases
2026-10-10 14:49:18 -07:00
YHH 53dfb1ace0 Merge pull request #11709 from esengine/feat/studio-sponsor
feat(studio): add a Support entry to Settings > About
2026-10-10 14:34:19 -07:00
YHH 16c63a2531 Merge pull request #11538 from esengine/fix/feedback-unread-refresh
feat(feedback): refresh the unread badge while open and announce new replies
2026-10-10 13:57:19 -07:00
YHH 9ce86fd5bb Merge pull request #12608 from esengine/feat/studio-rpm-package
feat(release): build and publish an rpm of Studio for Linux
2026-10-10 13:23:00 -07:00
YHH 6c5731c55b Merge pull request #12451 from esengine/perf/studio-gpu-mode
feat(studio): switch hardware acceleration off and show how the window is drawn
2026-10-10 12:49:36 -07:00
YHH fd5a34ca99 Merge pull request #12605 from xxoingr/fix/new-session-skipped-default
fix(config): report a skipped default on the new-session path too
2026-10-10 12:16:11 -07:00
yhh cfe4c4d4f8 fix(agent): stop copying the contract's criteria into every sealed bundle
The shadow bundle inlined the accepted contract's criteria and the full
verdict obligation list on every turn. Both scale with the number of
captured tests, and the bundle bytes changed each turn, so the
content-addressed store kept a fresh multi-megabyte copy per turn.

The bundle now names the contract revision by record digest only, and
files the obligations as their own object, which repeats nothing when a
turn leaves them unchanged.
2026-10-10 11:43:47 -07:00
YHH 9d4be2b6a1 Merge pull request #11535 from esengine/feat/auto-archive-11516
feat(studio): opt-in archiving of conversations inactive for N days
2026-10-10 11:40:36 -07:00
yhh 782e54b2fb fix(studio): point aria-controls at the QR block only while it exists 2026-10-10 11:31:27 -07:00
yhh 57be97a8dd Merge remote-tracking branch 'origin/studio' into upd-11709 2026-10-10 11:31:17 -07:00
yhh 2785a9b2fb refactor(i18n): move the notification texts into their own struct; stop polling feedback untouched for a month
Messages is back under the line ceiling with room for the next notification kind.
A report whose last update is over thirty days old no longer keeps the unread
poll running.
2026-10-10 11:31:11 -07:00
yhh cf78bab6b5 Merge remote-tracking branch 'origin/studio' into upd-11538 2026-10-10 11:30:18 -07:00
yhh 0a09f6c035 feat(release): build and publish an rpm of Studio for Linux
Fedora, RHEL and openSUSE cannot install the .deb. electron-builder now
also builds an .rpm, signed and mirrored with the same minisign flow, and
studio-manifest lists it under native_packages[linux-<arch>-rpm] beside the
unchanged .deb key. fpm spells the architecture of an .rpm name x86_64; the
packaging hook renames it to the release architecture and fails on any
spelling it does not know. The package job reads the rpm back for its name,
the helper's owner and mode, and the rpm's post-uninstall leaves the command
link alone on upgrade.
2026-10-10 11:13:32 -07:00
yhh c2b8c15626 fix(studio): report Electron's graphics status as stated and keep Settings off the line ceiling
The graphics panel no longer infers a driver fault from gpu_compositing: it
shows the raw status Electron reports, tells a saved choice from a launch-only
override, and says so when no status exists yet. The settings search moves into
its own hook so Settings.tsx has room again.
2026-10-10 10:38:33 -07:00
YHH 365b5ce0dd Merge pull request #12596 from esengine/fix/serve-submit-admission-and-turn-wait
fix(serve): judge POST /submit by what admission did with it
2026-10-10 10:35:43 -07:00
YHH 0adbdf9319 Merge pull request #12585 from esengine/fix/computer-snapshot-first-line
fix(computer): bound and escape application names in the snapshot and app list
2026-10-10 10:21:19 -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
yhh 6fd697a1c3 fix(studio): keep Settings under the file-size ceiling after the merge 2026-10-10 10:09:18 -07:00
YHH 8fdca29d84 Merge pull request #12588 from esengine/perf/gitcmd-driver-listing-scope
perf(gitcmd): skip the driver listing for subcommands that cannot run a driver
2026-10-10 10:07:29 -07:00
xxoingr dfbe397a0a fix(config): report a skipped default on the new-session path too
When the saved default_model is a decision source, a build that did not
ask to open on a fallback (reasonix run, the TUI) passes it over and
opens on the first conversation source, and said nothing about it:
ResolveNewSessionChatModel returned only (ref, fallback, ok), so the
boot had no skipped default to report, while the window path's
ResolveStartupChatModel carried one and emitted the notice.

Both resolvers now return one value, NewSessionModel{Ref, Fallback,
SkippedDefault}, and one helper decides that a saved default was passed
over: nothing configured serves it, or its source does not answer
conversation. newSessionModel takes SkippedDefault from whichever
resolver the build uses, so both paths emit the same
default_model_unavailable notice. What each path opens on is unchanged;
an unknown default is still kept on the no-fallback path for the CLI's
error, so it is not reported as skipped there.

Refs #12561
2026-10-11 01:07:07 +08:00
yhh 003a84b02a feat(plugin): pin MCP tool definitions at approval and hold changed tools
Record a digest of every tool definition the first time an authorized
server lists a non-empty catalog. A later listing that adds a tool or
changes any definition field is held out of the model's catalog and cache,
and a call to it is refused with the typed code mcp.tool_definition_held.
The user is told through a notice (mcp_tools_held), and accepts the held
definitions with 'reasonix mcp trust <name>' or POST /mcp/trust, which
advance the record and reconnect the server.

User-level servers keep one record for every workspace; project-level
servers keep one per workspace. Removing a server forgets its record.

Refs #12356
2026-10-10 10:06:10 -07:00
yhh 91c09a3653 Merge remote-tracking branch 'origin/studio' into upd-12451
# Conflicts:
#	desktop/electron/test/unit.mjs
2026-10-10 10:04:59 -07:00
yhh af86ae74f7 fix(feedback): announce a new maintainer reply even when the unread count does not change
The announcement compared only the aggregate unread count, so another reply on a
report that was already unread was never announced. Each report's latest
maintainer reply id is now tracked as well.
2026-10-10 09:52:52 -07:00
YHH 1234c49fbc Merge pull request #12590 from esengine/fix/wire-parity-embed
fix(repolint): wire-parity reads embedded structs and extends, declares PermissionRules
2026-10-10 09:41:23 -07:00
yhh 35e773c2c6 docs(hub): say what syncPins is for now 2026-10-10 09:40:49 -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 6c146088c7 Merge pull request #12599 from esengine/test/serve-trace-wait-on-liveness
test(serve): wait for a trace turn to go silent, not for a fixed time
2026-10-10 09:26:03 -07:00
yhh fe61b4c3d3 Merge remote-tracking branch 'origin/studio' into upd-11535 2026-10-10 09:22:23 -07:00
yhh 145285dcef fix(archive): never archive unread conversations, hold hand-edited days at the limit, draw pins from the kernel
An unread finish keeps a conversation out of the automatic sweep until it is
viewed. A hand-edited days value past the limit is held at the limit instead of
wrapping time.Duration. Pins are drawn from the tree's own pinned field;
localStorage only carries pins made before the kernel kept them and each is
sent once.
2026-10-10 09:22:23 -07:00
yhh 74d6e155f6 Merge remote-tracking branch 'origin/studio' into upd-11538 2026-10-10 09:18:38 -07:00
yhh 4bad69a161 Merge remote-tracking branch 'origin/studio' into upd-11709 2026-10-10 09:00:49 -07:00
yhh 4baacc9265 fix(serve): judge POST /submit by what admission did with it
POST /submit answered 409 busy.session_running whenever the turn it had just
started was already over by the time the handler sampled Running(), and also
for a submission parked behind a finishing turn that would run next. The
submit entry points now return the controller's admission result and the
handler refuses only a submission that was dropped.
2026-10-10 08:59:25 -07:00
yhh 5511d40317 test(serve): wait for a trace turn to go silent, not for a fixed time
runTrace gave a fixture turn a fixed 15s to close. A turn is several rounds of
host work, which a loaded Windows runner stretches well past that while the
turn is still emitting events. A turn is now hung only after a silent window,
with a total ceiling for one that never stops.
2026-10-10 08:42:10 -07: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 dfd848e811 fix(repolint): wire-parity flattens embedded structs and extended interfaces, and declares PermissionRules
An embedded struct's fields are its outer type's own on the wire, and an
interface's extended parents are part of its shape, but the checker read neither,
so the outer PermissionRules type could not be declared and a field added to it on
one side went unnoticed. Same-file embedded structs and extends parents are now
flattened; PermissionLists and PermissionRules are registered.
2026-10-10 07:32:27 -07:00
yhh 0c5b7a625c perf(gitcmd): skip the driver listing for subcommands that cannot run a driver
Refs #12532
2026-10-10 07:30:28 -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 411f0375a1 fix(studio): let threshold rows wrap under the label on narrow widths 2026-10-10 07:13:29 -07:00
yhh 778c97014d fix(computer): bound and escape application names in the snapshot and app list
The first line of a snapshot, the app list and the screenshot caption printed
the application's name raw, and the app list printed window titles escaped
but unbounded. Route them through the literal the modal names already use.
2026-10-10 07:06:48 -07:00
YHH d76a5c4b52 Merge pull request #12583 from esengine/release-notes-2.34.0
docs(release): Studio 2.34.0 release notes
2026-10-10 07:03:07 -07:00
YHH fa4eb9c8df Merge pull request #12552 from esengine/fix/glm-contract-by-declared-protocol
fix(effort): apply the GLM depth contract by declared protocol, not host
2026-10-10 06:55:56 -07:00
yhh 93d5c1eb7c fix(studio): keep pins the kernel has not confirmed from being auto-archived
A pin the kernel refused is rolled back and reported instead of staying
visible, and any remembered pin of a listed conversation the kernel does not
report as pinned is sent again on the next tree load, so a legacy pin in a
workspace added after the first sync is protected before its first sweep.
2026-10-10 06:41:45 -07:00
yhh 4631dc9d44 Merge remote-tracking branch 'origin/studio' into upd-11535 2026-10-10 06:36:12 -07:00
YHH afa05c1183 Merge pull request #12553 from esengine/fix/decision-source-not-a-chat-model
fix(models): a decision source is not offered, or accepted, as a conversation model
2026-10-10 05:03:19 -07:00
YHH 90bb7365ee Merge pull request #12546 from esengine/fix/typesafe-provider-probe
fix(providers): verify a decision source through its own protocol, not a model listing
2026-10-10 04:33:35 -07:00
yhh 2908a3575a docs(release): Studio 2.34.0 release notes 2026-10-10 04:14:17 -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 d6529d4932 Merge pull request #11692 from Guan0923/codex/studio-rewind-file-count
fix(studio): count files across the full rewind range
2026-10-10 03:40:43 -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
YHH 3eedd82740 Merge pull request #12579 from esengine/fix/installsource-windows-newline-test
test(installsource): make the alias quoting fixture creatable on Windows
2026-10-10 03:08:13 -07:00
yhh ba6cd5b843 test(installsource): drop the newline from the alias path fixture on Windows
A Windows file name cannot hold a newline, so the quoting test could not create its directory. The bidi override still exercises the literal-quoting path there.
2026-10-10 02:50:13 -07:00
xiexian 667368c3be fix(installsource): copy directory root aliases 2026-10-10 17:26:18 +08: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 5a74b65bbe Merge pull request #12110 from KHG420/codex/studio-skill-root-alias
fix(installsource): follow skill collection root aliases
2026-10-10 01:54:26 -07: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 6d7b27c718 Merge pull request #12542 from esengine/fix/computer-effect-grading-windows
feat(computer-helper/windows): report modals and grade typing by read-back
2026-10-10 01:27:42 -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 6e6234b833 Merge pull request #12517 from esengine/fix/plan-mode-refusal-cause-exit
fix(planmode): a refused call names its command, cause class and way out
2026-10-10 01:13:50 -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
YHH 9971114aa4 Merge pull request #12541 from esengine/fix/computer-effect-grading
feat(computer): grade each step's effect and carry the modal that holds input
2026-10-10 00:03:22 -07:00
XTLine f9a0a0926f fix(agent): preserve typed stream failure attribution 2026-10-10 15:03:20 +08:00
esengine 557b160d5e fix(models): a decision source is not offered, or accepted, as a conversation model
The model switcher listed every configured source, so a decision wire
(kind typesafe) appeared beside chat models and choosing one failed in the
provider registry as an unknown kind.

GET /models now answers one job: chat by default, answers=decision for the
decision sources, answers=all for the management views. The switch, the
default model and every role refuse a model whose wire does not answer that
job with a typed code (model.decision_only, model.not_decision_source), and
the provider builder refuses it with config.AnswersMismatchError. A saved
default that names a decision source is passed over like a stale one.
2026-10-09 23:58:44 -07:00
esengine 8b2ebe6e43 A connection that typed its own supported_efforts keeps its stored choice:
the contract translation (disabled -> none/low, enabled -> default) now
applies only when the menu is the contract, the same rule
InheritedEffortOfficial already used. Before, the Token Rhythm preset shape
sent thinking=enabled with reasoning_effort=max for a stored disabled.
2026-10-09 23:52:50 -07:00
YHH 39d06cf514 Merge pull request #12539 from esengine/fix/boot-shell-probe-off-handshake
fix(boot): bound and persist Windows shell discovery so the handshake is not held behind it
2026-10-09 23:47:27 -07:00
YHH 6cda7afaf2 Merge pull request #12540 from esengine/fix/ask-card-answer-affordance
fix(studio): make the ask card's free-text answer discoverable and name the wait
2026-10-09 23:22:46 -07:00
YHH ccb89c69c0 Merge pull request #12538 from esengine/fix/legacy-import-skip-reasons
fix(import): list the 1.x sessions an import could not read, with a typed reason
2026-10-09 22:58:30 -07:00
esengine 998e0fcd3d style(providers): satisfy errorlint and usestdlibvars in the decision check 2026-10-09 22:36:51 -07:00
YHH 0ee3e741a1 Merge pull request #12482 from SivanCola/fix/studio-sidebar-footer-entries
fix(studio): consolidate sidebar footer entries / 合并侧栏重复底部入口
2026-10-09 22:30:34 -07:00
esengine bb7e5947f5 test(providers): cover decision-source check statuses, and give a refused request its own code 2026-10-09 22:13:31 -07:00
xiexian 85e6a14f87 fix(installsource): follow skill collection root aliases 2026-10-10 13:05:09 +08:00
esengine 8e7e41505f fix(providers): verify a decision source through its own protocol, not a model listing 2026-10-09 21:55:55 -07:00
YHH a7109c5bf1 Merge pull request #12536 from esengine/fix/permission-rule-unknown-tool
fix(permissions): report and refuse rules that name no registered tool
2026-10-09 21:55:50 -07:00
Guan09 b792200d24 fix(studio): align rewind counts with affected files 2026-10-10 12:50:16 +08:00
yhh 7219a57b82 fix(computer-helper/windows): grade typing on the exact change, and read no value for keys
key and hold_key need only the modal their keys go to, so they no longer
read the focused element's value. Typing is confirmed only when the
read-back differs from the value before by exactly the typed text put in at
one place, replacing what was selected there; the changed span is taken
from either end, since repeated characters make it ambiguous. A value that
merely changed and contains a short typed text, as autocomplete or another
writer can leave it, is unverifiable.
2026-10-09 21:41:05 -07:00
yhh 792be5d35d Merge branch 'fix/computer-effect-grading' into fix/computer-effect-grading-windows 2026-10-09 21:38:51 -07:00
yhh b304663fa3 fix(computer): bound and escape a modal's names before the model sees them
A modal's title and the name of the window it holds come from the
application. They were quoted but not bounded, so a 100000-character title
reached the model as about 100 KB, while every other label in the tree is
clipped to 160 by the helper. Both names now go through
textutil.BoundLiteral at that length: controls, bidi overrides and
invisible characters show as visible escapes, and so does a quote, so a
name cannot close its own quotation.
2026-10-09 21:38:05 -07:00
YHH cc4f6eaaf2 Merge pull request #12530 from esengine/perf/skill-roots-once
perf(skill): resolve each discovery root once per pass
2026-10-09 21:28:32 -07:00
yhh 0032370259 feat(computer-helper/windows): report modals and grade typing by read-back
The Windows helper sent keys to whatever held the application's focus and
answered success. With Notepad's error dialog up, `type "hello"` returned
Done:1 while the text went to the dialog's OK button (#11366).

The helper now reads two structural facts, never a title or a message:
- a Win32 modal: an enabled window of the process whose owner chain holds a
  window of the same process it has disabled (MessageBox, DialogBox, common
  dialogs);
- a dialog drawn inside its window that declares WindowPattern.IsModal, which
  is what Windows 11 Notepad shows: one top-level window, still enabled, with
  a XAML popup holding its input.

With them:
- type and paste are refused as computer.blocked, before anything is sent,
  when focus sits in a modal on an element that takes no text: a letter there
  can be the dialog's mnemonic. Typing into a field of a modal (a file name
  in a save dialog) still goes in;
- a ref or point step on an element in a window the application has disabled
  is refused as computer.blocked, naming the modal that holds it;
- key and hold_key still go (Enter and Escape answer a dialog) and say which
  modal they went to;
- type, paste and set_value read the focused element's value before and
  after: confirmed when what was typed is in, suspected_noop when nothing
  changed, unverifiable when it cannot be read (no value, a password, or a
  value too long to compare). Values stay in the helper; set_value no longer
  sends its read-back across the protocol;
- a snapshot lists the modals, and marks the modal's line.

The fixture gains a message box mode and an owned form with a text field;
the live tests run in the logged-in desktop session.
2026-10-09 21:25:41 -07:00
YHH edb496c8e1 Merge pull request #12529 from esengine/perf/control-image-routing-config
perf(control): read the config for image routing only when a turn has attachments
2026-10-09 21:12:36 -07:00
esengine 8a64d2b8db feat(computer): grade each step's effect and carry the modal that holds input
A computer_act step that the helper accepted read to the model as done,
whether or not anything reached the target. On Windows, typing into Notepad
while an error dialog held its input returned Done:1 and the text went
nowhere, and the dialog showed in the tree only as an unnamed window.

The helper protocol gains three optional fields, all read from structure:
- a step's result may carry an effect: a closed class (confirmed,
  unverifiable, suspected_noop), the evidence kinds it rests on
  (value_readback, value_unchanged), and the modal its input went to;
- a failure may carry blocked_by, with the new code computer.blocked for
  input a modal would swallow;
- a snapshot may list modals: ref, title, and the window each one holds.

The kernel owns the grading contract: a class without the evidence that
proves it, an unknown class, or no effect at all (an older helper, or the
macOS helper until it reports one) settles as unverifiable. A step that
sends nothing, such as wait, carries no effect. computer_act says each
step's effect after its note and names the modal in a blocked failure; a
snapshot opens with the modals holding the application's input.

Tool schemas and descriptions are unchanged, so the cache-stable prefix is
byte-identical. Approval is untouched: grading only adds information, and the
new refusal only sends less.
2026-10-09 21:12:23 -07:00
YHH 215bfe8dce Merge pull request #12523 from esengine/fix/host-crash-trail
fix(host): quiet routine request logging and keep a crash trail when the kernel dies
2026-10-09 20:58:04 -07:00
esengine 2b2651d9f5 test(planmode): range over strings.SplitSeq
Refs #12508
2026-10-09 20:51:19 -07:00
esengine aa9266c5da test(sandbox): make the swapped-during-probe test depend on the digest alone
The replacement now keeps the size and restores the mtime, so only the digest
comparison can tell it apart; it fails with the digest taken after the probe.

Refs #11434
2026-10-09 20:50:21 -07:00
YHH 7eab681aa9 Merge pull request #12512 from xxoingr/fix/mcp-json-server-limit
fix(installsource): refuse a .mcp.json with more servers than the limit
2026-10-09 20:44:30 -07:00
esengine cc66e5bb2b fix(sandbox): digest the shell before its probe and document proof scope
The digest recorded for a persisted proof is taken before the launch, so an
executable swapped during the probe is not vouched for. The Probe field comment
now says the memo is private while proofs still persist.

Refs #11434
2026-10-09 20:42:33 -07:00
YHH 4f02f9c8bf Merge pull request #12520 from esengine/fix/md-only-turn-verification-waiver
fix(readiness): waive the verification ask when every change this turn is prose
2026-10-09 20:25:09 -07:00
yhh f7a6153d2c fix(import): attribute read failures to corrupt, keep copy_failed for writes
A failure with no sentinel on the read side (missing object, bad content
reference, size or digest mismatch) was reported as a failed copy. Reads now
default to corrupt, writes are classified separately, a folder without a
manifest is not listed as a session, and each copy button names its session.
2026-10-09 20:18:53 -07:00
esengine 8d08136127 fix(boot): bound and persist Windows shell discovery so the handshake is not held behind it
Bash discovery launched each Git Bash candidate serially (10 s each), then a
PowerShell check, and remembered the proof only in process, so every cold start
repeated it in front of the Studio handshake. Candidates are now probed with a
hedge, the search shares one budget and falls back with probe_timeout, and a
successful launch is persisted keyed by path, size, mtime and content digest
(treated as a miss on any doubt). Boot gains shell, prompt and environment
phase marks so the start-up trace names every stretch.

Refs #11434
2026-10-09 20:04:14 -07:00
esengine ffe7638955 fix(studio): make the ask card's free-text answer discoverable and say so in the status line
The free-text box of a question card stayed display:none until Other or a
single pick was chosen, so a card with only fixed options showed nothing
typeable while the run line read "Waiting on you" and the open plan kept
turning its in-progress arc.

The box is now always present on a live card and typing into it is an
Other answer, so there is one control for the free-text path. The run line
names the wait ("Waiting for your answer") and the plan stops turning its
arc while a decision is pending.
2026-10-09 20:00:20 -07:00
yhh 8edecb10ce fix(import): report each 1.x session an import could not read, with a typed reason
The legacy import dropped unreadable sessions behind event.Discard and
returned only a warning count. The migration layer now produces one skip
record per session (name, source path, reason code taken from the reader's
sentinel errors), the import response carries them, and the storage panel
lists them with the fact that the originals are untouched.
2026-10-09 19:46:25 -07:00
SivanCola b4b9fb364e fix(studio): preserve navigation action identities
Problem: The browser contrast guard timed out opening Settings after the
duplicate sidebar footer was omitted beside visible icon navigation.

Root cause: The icon rail's Settings and Account controls did not carry the
chrome.settings and chrome.account identities declared for those actions.

Fix: Declare each footer action identity alongside its icon, label and
destination, and render it on the rail button. Extend the interaction test to
verify those identities and destinations through both footer locations.

Verification: The new identity assertion failed on the previous rail and
passed after the fix. All 36 related interaction tests, production and perf
builds, ui-census invariants and 10 fixture reports passed. The CI browser
guards (intrinsic, contrast and wheel) plus perf/side.mjs passed. Formatting,
go vet, make lint, make check, builtin/boot Go tests and diff checks passed.
2026-10-10 10:42:05 +08:00
esengine 68f7df17ff fix(permissions): report and refuse rules that name no registered tool
A rule such as ask=["rm"] parses but its tool name matches no tool, so it never
fires and the user believes a boundary exists that does not. Saving a rule whose
tool no registered tool (or MCP/extension namespace) can match is now refused
with permissions.rule_unknown_tool; rules already in the file are kept, listed
as dormant in the permissions panel, and announced once at load with a typed
permission_rules_dormant notice.
2026-10-09 19:16:42 -07:00
yhh bfe720403f perf(skill): resolve each discovery root once per pass and share one pass across the built-in subagent tools
Store.roots canonicalised every directory up to three times per call, and
BuiltinSubagentTools ran a full discovery pass for each of the five built-in
skills it checks. On Windows each canonicalisation is a chain of directory
lookups, so every boot.Build paid for around 120 of them in this one step.

Refs #12521
2026-10-09 19:00:31 -07:00
yhh f177f4fbe5 perf(control): read the config for image routing only when a turn has attachments
resolveTurnImages and withVisionRouting loaded the full project config on
every turn to decide how to route images, including turns with no image at
all, where neither answer is used.

Refs #12521
2026-10-09 19:00:31 -07:00
YHH 309e6fb68d Merge pull request #12525 from esengine/ci/windows-full-timeout-budget
ci: raise the Windows package timeout to 18m and print the slowest boot tests on failure
2026-10-09 18:36:49 -07:00
yhh 8ab28a89fb ci: raise the Windows package timeout to 18m and print the slowest boot tests on failure
internal/assembly/boot took 478-717s on the Windows runner across 12 push runs, so the 12m per-package timeout panicked. The full step now tees go test -json and, on failure, prints the failed tests' output and the 15 slowest boot tests.
2026-10-09 18:20:31 -07:00
yhh 69266faf66 fix(readiness): a failed named-path write keeps its targets in the changed set
tool.after can report a finished write as failed, and a move can stop
half-done, so a failure is not proof that nothing was written. Non-prose
targets of a failed named-path call now keep the generic check owed. The
declared-checks cause also covers checks captured at task start.

Refs #12519
2026-10-09 18:06:41 -07:00
yhh c77b9fd284 docs(host): say which log files are redacted; show the exit notice in the page's language
The crash files are the runtime's raw output and are not redacted, only
shell.log and host.log are. The notice now follows the same language the
page draws in instead of the machine locale.
2026-10-09 17:38:16 -07:00
yhh 9e600e1750 fix(host): keep routine polling out of the INFO log and leave a crash trail when the kernel dies
A Studio kernel that died of a runtime fatal left no readable trace: its
stderr sat in host.log, which request logging rotated away every few
minutes, and the shell quit without recording how the kernel ended.

Routes that are polled declare themselves with routine(); a successful
request to one logs at Debug, a refused one stays at INFO. The kernel
mirrors runtime crash output into a bounded crash directory the shell
names, the shell quotes the tail into shell.log and the next launch tells
the person once.
2026-10-09 17:04:16 -07:00
yhh fb0ecb026e fix(readiness): a failed command that may have written is not a proven prose-only change
Failed non-named-path mutations leave the extent of the change unestablished,
so the prose-only waiver no longer applies. The ask now names an unestablished
extent and declared checks as causes, spells paths from the workspace root, and
the trace fixtures are regenerated.

Refs #12519
2026-10-09 16:59:02 -07:00
YHH 65b51b7c02 Merge pull request #12518 from esengine/fix/gitcmd-no-path-format-dependency
fix(gitcmd): resolve repository paths without rev-parse --path-format
2026-10-09 16:11:12 -07:00
yhh 433059e4ba fix(readiness): waive the generic verification ask when every change this task made is prose
The waiver read the whole workspace scan, so a README edit in a repository
with any code was still held to a verification command. It now reads the paths
the task changed: all prose (.md/.rst), scope established, no declared check.
The ask also names the changed paths that keep it owed.

Refs #12519
2026-10-09 16:08:37 -07:00
YHH 98a522aa84 Merge pull request #12511 from xxoingr/fix/tui-ctrl-z-suspend
fix(tui): Ctrl+Z suspends the TUI to the shell
2026-10-09 15:57:37 -07:00
YHH 0f337099d6 Merge pull request #12510 from xxoingr/fix/tui-empty-enter-tail
fix(tui): Enter on an empty composer returns to the newest output
2026-10-09 14:59:31 -07:00
YHH 1bb48c6363 Merge pull request #12505 from esengine/fix/model-switch-while-bg-jobs-12335
fix(serve): say whether a turn or leftover background jobs block a model switch
2026-10-09 14:38:50 -07:00
YHH e1ff932a15 Merge pull request #12506 from esengine/fix/foreign-open-no-copy-12474
fix(session): opening a 1.x conversation no longer forks a copy
2026-10-09 13:15:00 -07:00
yhh 15a2ae11d9 fix(gitcmd): resolve repository paths without rev-parse --path-format
Git before 2.31 echoes the unknown option as an extra output line, which
Open reported as not-a-repository. Normalise the git dir, common dir and
work tree in Go instead, and do the same for the index path lookup.
2026-10-09 13:12:28 -07:00
YHH 58f599ca1c Merge pull request #12171 from XTLine/feat/workspace-branch-switch
feat(session): check the workspace out to another local branch
2026-10-09 12:07:31 -07:00
YHH b3e1811916 Merge pull request #12498 from esengine/feat/provider-reasoning-shape-copy
feat(providers): word the thinking parameter from the protocol's declared effort field
2026-10-09 10:27:05 -07:00
esengine cbc8086776 fix(planmode): bound and escape the echoed command, name dynamic subcommands
The refusal echoed the command verbatim, so a long or multi-line command
could flood the message or forge a Cause line. The subject now goes through
textutil.BoundLiteral. A compound statement whose subcommand is dynamic
(git $X; ls) is attributed to shell expansion instead of leaking the
internal placeholder as an unknown program. The declared-writer cause no
longer claims a tool declared that it changes state.

Refs #12508
2026-10-09 10:04:13 -07:00
yhh 1f8f3711c8 Merge remote-tracking branch 'origin/studio' into fix/plan-mode-refusal-cause-exit 2026-10-09 10:03:24 -07:00
YHH b5082f4f5b Merge pull request #12477 from esengine/fix/rewrite-box-fit-height
fix(composer): size the rewrite box to its text, as the composer does
2026-10-09 09:43:49 -07:00
xxoingr 85ed87426d fix(installsource): refuse a .mcp.json with more servers than the limit
The install preview always shows every high-risk action, and an entry
with "tier": "eager" is high risk. A marketplace is capped at 64 plugins
and a skill repository at 200 skills, but a .mcp.json had no cap: a
package could ship tens of thousands of eager entries, bounded only by
the 2 MB remote read, and every one of them would be displayed.

parseMCPJSON, which planning runs for both a local and a fetched
.mcp.json, now refuses a file declaring more than maxMCPJSONServers (64,
the marketplace cap) with ErrInvalidManifest, before any entry is turned
into an action. The refusal is a hostFactError, so the caller reads the
declared count and the limit rather than an omitted detail.

With kind=mcp, a fetched URL whose path names a .mcp.json now returns
its parse error, as a local .mcp.json already did, instead of
ErrUnsupportedKind; the over-limit refusal otherwise never reached a
remote caller. Any other document fetched with kind=mcp, such as a
SKILL.md, still answers ErrUnsupportedKind.

Refs #12488
2026-10-10 00:34:17 +08:00
XTLine 905b74156a fix(gitcmd): resolve canonical repository paths through aliases 2026-10-10 00:29:54 +08:00
esengine 89668ab2a9 fix(planmode): a refused call names its command, cause class and way out
The planning gate recorded every call it could not prove read-only as a
side effect and told the model it "would change state", with no command,
no cause and no alternative. The effect is now Unclassified when the host
only failed to prove a read, SideEffect when a tool declares itself a
writer or an argument makes a reader write, and the refusal carries the
classifier's structural proof (unknown program, shell construct from the
parser, writing arguments, background, undeclared tool). The marker now
defines read-only shell in the same terms.

Refs #12508
2026-10-09 09:27:18 -07:00
YHH e3a4fa4c2b Merge pull request #12092 from XTLine/fix/composer-branch-chip
fix(studio): read the composer branch from git, not the capability scope
2026-10-09 09:13:53 -07:00
xxoingr 34b00e408d fix(tui): Ctrl+Z suspends the TUI to the shell
1.x suspended to the shell on Ctrl+Z, as terminal programs do; 2.x left
the key unbound, so it did nothing and there was no way to drop to the
shell without quitting.

Ctrl+Z now returns tea.Suspend from shortcutKey. Bubble Tea releases the
terminal (alternate screen, mouse tracking, bracketed paste) before
stopping the process and restores and repaints it on fg, so nothing of
1.x's manual mouse reset is needed. On Windows Bubble Tea has no job
control and ignores the request. GUIDE.md and its Chinese version list
the key.

Refs #12144
2026-10-09 23:52:26 +08:00
xxoingr 2c11679055 fix(tui): Enter on an empty composer returns to the newest output
After scrolling back through the transcript, Enter on an empty composer
did nothing; getting back to the live output took Ctrl+End or the wheel.
1.x jumped to the bottom and followed the tail again on an empty Enter,
idle or while a turn ran.

An empty (or blank) Enter now does the same in full-screen mode, through
followTail, which Ctrl+End also uses. It still sends nothing. Inline mode
has no in-app view to move and is unchanged. GUIDE.md and its Chinese
version note the behaviour on the Enter row.

Refs #12144
2026-10-09 23:49:34 +08:00
esengine da4e3d97d7 fix(serve): tell a running turn from leftover background jobs when a model switch is refused 2026-10-09 07:12:27 -07:00
yhh 9ebbde6678 fix(session): a rebuilt 1.x conversation no longer forks a copy on open
Swapping in this build's system prompt after a model switch made the
transcript differ from the 1.x log, so the save that follows moved the
conversation to a new session. The system prompt is host contract, not
conversation; the unchanged check now leaves it out.

Refs #12474
2026-10-09 06:50:08 -07:00
YHH cdfe244aa3 Merge pull request #12037 from esengine/fix/bound-install-preview-text
fix(installsource): bound and sanitise package-supplied preview text
2026-10-09 06:45:16 -07:00
YHH d45419d5f1 Merge pull request #12476 from esengine/fix/job-completion-notice-code
fix(jobs): give background job completion notices a typed code and payload
2026-10-09 06:44:42 -07:00
XTLine dce0b0c2fb fix(studio): inherit tooltip guards and isolate branch fixture line endings 2026-10-09 20:55:30 +08:00
XTLine 4a123ae574 fix(studio): enforce tooltip regression guard and accessible naming 2026-10-09 20:53:56 +08:00
esengine f26b7cc8e4 test(studio): let the Chat fixture entry offer the thinking switch 2026-10-09 05:27:56 -07:00
esengine c3752645d7 fix(queue): keep the queued-line editor inside the rows the list shows
The list scrolls inside a height the composer's aux area caps, so an editor
grown to its text could be taller than the list and get cut off at an edge.
The fit hook now takes the room left by the list, and the edited row is
scrolled into view. The browser check asserts the whole row sits inside the
list's visible area at desktop and phone width.

Refs #12477
2026-10-09 05:19:34 -07:00
esengine 24f8a0fe2d fix(providers): resolve the effort field per entry and cover existing configs
The effort field is now resolved from the entry's reasoning protocol rather
than its kind, so an auto entry that resolves to a bespoke shape (Zhipu,
MiniMax, LongCat, DeepSeek) no longer claims reasoning_effort. The catalog
carries the protocols the field holds under and the panels read it instead of
keeping a copy. Adds the Anthropic none case and an end-to-end check that
existing effort-only configs keep their request bytes.
2026-10-09 05:18:30 -07:00
YHH 2b9aab4248 Merge pull request #12497 from esengine/fix/studio-wireparity-duplicate-look
fix(repolint): drop the duplicate look.ts mirror declarations that broke the studio build
2026-10-09 05:14:27 -07: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
yhh cbc1a537ff docs(installsource): describe hiddenActions and previewTruncated as they behave
The package doc still said a cut of 50 actions sets previewTruncated, and the
field comment said escaped text sets it. The test-only PreparePlugin option
is marked as such.
2026-10-09 05:08:09 -07: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
yhh 961f4e0c3a fix(repolint): drop the duplicate look.ts mirror declarations that broke the studio build
#12389 and #12035 each added the tsLookFile constant and an appearanceView mirror; the two merged cleanly as text and left tools/repolint unable to compile (tsLookFile redeclared). Keep one constant and one appearanceView entry, plus the zoomRangeView entry #12035 added.
2026-10-09 05:01:15 -07:00
YHH f1a9256ade Merge pull request #12486 from esengine/fix/windows-git-bash-discovery
fix(sandbox): find Git Bash through its registry install path and say when the shell falls back to PowerShell
2026-10-09 04:55:48 -07:00
yhh b51fd9cbb3 fix(installsource): report hidden steps and cut text separately
PreviewTruncated also fired when only whole steps were left out, so the
confirmation said text had been shortened when none had. It now means that
text was cut or escaped, and HiddenActions carries the step count. The card
no longer repeats the plan-level sentence; it shows a short marker aligned
with the other rows.
2026-10-09 04:52:57 -07:00
YHH d3c3e861de Merge pull request #12035 from esengine/fix/ui-scale-single-range-and-keys
feat(desktop): one declared interface-scale range, and Ctrl/Cmd +/-/0 to change it
2026-10-09 04:47:32 -07:00
XTLine 58807823ad fix(session): bound branch lease waits and distinguish workspace refusals 2026-10-09 19:44:27 +08:00
XTLine 9a114fd836 fix(studio): bound branch tooltips and merge latest studio 2026-10-09 19:39:22 +08:00
esengine 70621ada23 feat(providers): word the thinking parameter from the protocol's declared effort field
The protocol table now declares where a chosen effort level lands in the
request body (reasoning_effort, reasoning.effort, output_config.effort). The
protocol catalog and the provider list carry it, and the add, detail and edit
panels word their help and example body from it instead of naming the Chat
field for every wire.

The Responses wire now takes the same thinking switch, reasoning_protocol and
declared levels as Chat in all three panels and in the edit endpoints.
reasoning_protocol=none is honoured when resolving the effective effort, and
a declared openai protocol lists its own ladder as the request vocabulary.
2026-10-09 04:36:50 -07:00
YHH 257f39e8c9 Merge pull request #12039 from esengine/fix/acknowledgments-owner-filter
fix(docs): keep the owner, aliases and tool identities out of the acknowledgments table
2026-10-09 04:34:33 -07:00
esengine 51fef65939 test(perf): name the viewport check for what it measures and tighten the queue editor bound
The shrunken-viewport case is renamed to say it checks the box stays within
60% of a shorter viewport with the caret at the end. The queue editor check
now compares against its computed cap and the room the list leaves. The
editor's own max-height was dead (the composer's textarea rule wins), so it
is removed again.
2026-10-09 04:27:24 -07:00
YHH f05a3e3b79 Merge pull request #12083 from esengine/fix/compaction-failure-visible-and-bounded-notice
feat(compaction): announce a held automatic compaction
2026-10-09 04:27:24 -07:00
yhh 293367b5cb fix(sandbox): name the interpreter in use when advising how to silence the PowerShell fallback
prefer=powershell tries Windows PowerShell 5.1 before pwsh, so advising it to a pwsh 7 user would trade away && chains. Advise prefer=pwsh when the interpreter in use chains.
2026-10-09 04:14:33 -07:00
YHH b7afe286eb Merge pull request #12469 from esengine/fix/compaction-truncated-summary-retry
fix(compaction): retry a length-truncated summary once with a larger output cap
2026-10-09 04:14:21 -07:00
YHH f33b563b13 Merge pull request #12468 from esengine/fix/nav-hint-clipped
fix(studio): icon-rail hover hints are no longer clipped or covered
2026-10-09 04:12:13 -07:00
YHH e58220852a Merge pull request #12465 from esengine/fix/receipt-card-noise
fix(receipt): keep host-owned state and the English sentence off the delivery card
2026-10-09 04:10:23 -07:00
yhh e4846a0c5a fix(sandbox): find Git Bash through its registry install path and say when the shell falls back to PowerShell
Auto-detection never looked at where Git for Windows records its own install, so a Git outside the standard roots whose git is not on PATH was invisible and the session silently ran PowerShell. Add that registry location as the last bash candidate source, make a failed probe say whether it timed out, give the probe 10s instead of 3s for a cold start, and write the fallback warning that the startup notice already carries.
2026-10-09 04:09:59 -07:00
yhh 81f310d55a test(market): hold the theme gate and every count field it sums
The theme category check had no test that reached ErrNotTheme. A marketplace
of fifty theme packages plus one with a hook must be refused and keep the
hook step visible in the envelope; sixty pure themes must pass. A table
sets each count field and the runtime alone, so dropping any term from the
sum is caught. install_source gains a test-only PreparePlugin option so the
market layer can plan a marketplace without a network.
2026-10-09 04:08:19 -07:00
YHH d256440031 Merge pull request #12109 from KHG420/codex/studio-registered-skill-discovery
fix(installsource): report registered skill discovery by source
2026-10-09 04:08:18 -07:00
yhh 2e0c5ef3cc fix(installsource): gate on the unprojected plan and keep host sentences whole
The theme gate read the bounded preview, which lists at most 50 steps while
a marketplace may hold 64 plugins and a skill repository 200 skills, so a
hook or runtime past the cut skipped the check and the confirmation. The gate
now reads the unprojected actions; the preview keeps every high-risk step,
caps the rest, and reports how many it left out; the real skill count is
shown. Package inventory returns the package's own text again, and the
display projection is a separate call.

The FULL TRUST reason embedded the runtime command inside a sentence that was
then stripped as prose, so an unterminated escape sequence in an argument
swallowed the warning. The command is now rendered literally after the host
sentence. BoundProse reports removal as well as truncation, BoundLiteral
cannot be mimicked by typed escapes, and colliding display keys are flagged.
2026-10-09 03:54:24 -07:00
YHH de4e0e6a9d Merge pull request #12107 from KHG420/codex/studio-skill-install-shadow-diagnostics
fix(installsource): report skill installation shadowing accurately
2026-10-09 03:50:43 -07:00
YHH 6a901e8458 Merge pull request #12099 from KHG420/codex/studio-installed-scenario-guide
docs(studio): run scenario exercises from installed packages
2026-10-09 03:35:06 -07:00
yhh cf1db1d307 feat(studio): launch-only GPU override (env or flag), graphics hint points at reduced effects, search hides the setting outside the shell 2026-10-09 03:26:07 -07:00
XTLine 3b839b0821 feat(session): check the workspace out to another local branch
Stack on the refreshed read-only branch chip. Guard checkout atomically against all active panes and unfinished jobs in this host, preserve queued input cancellation, honor existing writer leases, and protect ignored local files including late-created collisions.
2026-10-09 18:22:55 +08:00
SivanCola 019bd48bd8 fix(studio): consolidate sidebar footer entries
Problem: Usage, feedback, account/workspace and settings appeared twice when
the global icon navigation and workspace sidebar were both visible.

Root cause: Sidebar rendered its footer unconditionally, independent of the
navigation visibility already owned by App.

Fix: Pass the existing navigation visibility into Sidebar and render the
footer only when navigation is hidden. Preserve the fallback entries in off,
collapsed-only and narrow-window modes, plus existing feedback and settings
routing. Update layout/parity docs, pending release notes and fixture images.

Verification: 36 relevant frontend interaction tests, 11 release-note/link
tests and the frontend production build passed. Formatting, go vet, make lint,
make check, builtin/boot Go tests and diff whitespace checks passed. Browser
fixtures cover light/dark wide windows and narrow drawers.
2026-10-09 18:20:18 +08:00
YHH 4583c328c7 Merge pull request #12389 from f1shsmell/chore/repolint-look-wire-parity
chore(repolint): declare six look.ts wire mirrors
2026-10-09 03:19:35 -07:00
esengine c6ac527dc5 fix(queue): fit the queued-line editor to its text and cap heights in dvh
The queued-line editor took its height from the number of line breaks, the
same defect as the rewrite box, so it now uses the shared fit-to-content hook.
Both height caps use dvh (with a vh fallback on the same line) so a soft
keyboard shrinking the viewport shrinks the cap. The browser check gains the
queue editor and a shrunken-viewport case; the harness can read a seeded
queue line back.
2026-10-09 03:17:17 -07:00
esengine 0b34dac58d fix(jobs): name the job in a failed-job notice and cover the killed path
A failed job's notice now carries the same JobNotice payload as finished and
killed ones, plus the job function's own error text, so a frontend words
it from the label or id and shows the raw error beneath. A failure stored
without a payload still renders its text. Adds an emission test for the
killed notice.
2026-10-09 03:08:55 -07:00
YHH 6a822a2e20 Merge pull request #12104 from chuiwenwei/feat/memory-omitted-matches
feat(memory): report omitted matches on the memory tool paths
2026-10-09 03:03:47 -07:00
XTLine 4916417b29 fix(studio): read the composer branch from git, not the capability scope
Rebase the read-only branch chip onto the current Pane split. Keep the stylesheet within budget, register the complete workspace Git wire contract, and verify refresh after turn completion.
2026-10-09 17:59:33 +08:00
yhh 7c5ce2b2c4 fix(serve): register the auto-archive routes with the other session routes 2026-10-09 02:48:11 -07:00
YHH ccf26ffb48 Merge pull request #11572 from Guan0923/codex/studio-rewind-checkpoint-state
fix(checkpoint): sync file state after code rewind to prevent false conflicts
2026-10-09 02:45:55 -07:00
yhh 00b01ef3b9 test(desktop): drop an unused binding 2026-10-09 02:27:12 -07:00
yhh cf190c021b fix(desktop): show the interface-size keyboard hint only where the chords are registered 2026-10-09 02:26:14 -07:00
esengine 2d112a548a fix(composer): size the rewrite box to its text, as the composer does
The rewrite box took its height from the number of line breaks, so a long
paragraph written as one line showed about 60px of it. The composer's
fit-to-content logic is now a shared hook used by both, with the box capped
at 60vh. A browser check covers a long single line at desktop and phone
width in both themes.
2026-10-09 02:22:52 -07:00
esengine 01e2f80ad3 fix(jobs): give background job completion notices a typed code and payload
The completion notice carried only English text with a numeric id, so a
frontend could neither localize it nor name the job. Finished and killed
notices now carry a code and a JobNotice payload (kind, id, label); a
failure carries a code and keeps its diagnostic. Studio and the terminal
UI word them from the code; uncoded notices from older sessions still
render their stored text.
2026-10-09 02:18:50 -07:00
YHH f303de0326 Merge pull request #11374 from drakeo338/claude/11372-fix
fix(config): keep the config edit lock directory per user and refuse a foreign owner
2026-10-09 02:15:05 -07:00
yhh 87a6a8132c test(compaction): size the held-notice fixture so the retry stays held under the growth-bounded release 2026-10-09 02:12:45 -07:00
YHH 3da403da04 Merge pull request #12135 from KHG420/codex/studio-memory-draft-lifetime
fix(studio): preserve memory drafts and pending writes
2026-10-09 01:59:07 -07:00
yhh 911f9cf471 Merge studio into fix/compaction-failure-visible-and-bounded-notice 2026-10-09 01:53:45 -07:00
yhh 9de1e7adfb Merge remote-tracking branch 'origin/studio' into upd-11535
# Conflicts:
#	desktop/frontend-next/src/actions.ts
#	desktop/frontend-next/src/i18n/en_settings.ts
#	desktop/frontend-next/src/port/boundary.ts
#	desktop/frontend-next/src/port/hub.ts
#	desktop/frontend-next/src/port/mock_boundary.ts
#	desktop/frontend-next/src/port/port.ts
#	desktop/frontend-next/src/port/sse_boundary.ts
#	desktop/frontend-next/src/ui/App.tsx
#	desktop/frontend-next/src/ui/Workspaces.tsx
#	desktop/frontend-next/vite.config.ts
#	internal/contract/config/render.go
#	internal/frontend/serve/hub_tree.go
#	internal/frontend/serve/routes.go
#	internal/state/sessionstore/branch.go
2026-10-09 01:53:26 -07:00
yhh 0e76f564ad Merge remote-tracking branch 'origin/studio' into upd-12451
# Conflicts:
#	desktop/frontend-next/src/state/prefs.ts
2026-10-09 01:51:54 -07:00
yhh 43a443ca15 Merge remote-tracking branch 'origin/studio' into upd-11709
# Conflicts:
#	desktop/frontend-next/src/main.tsx
2026-10-09 01:51:44 -07:00
yhh 17ea70c8df Merge studio into fix/acknowledgments-owner-filter 2026-10-09 01:51:38 -07:00
YHH a20c5f107b Merge pull request #12126 from KHG420/codex/studio-package-install-connection
fix(studio): scope package installation forms to connection
2026-10-09 01:42:29 -07:00
yhh d81168f2a3 build(desktop): record rivo/uniseg checksums 2026-10-09 01:40:54 -07:00
yhh 3eee28cabe Merge studio into fix/ui-scale-single-range-and-keys 2026-10-09 01:40:53 -07:00
yhh 4e8b8e7963 Merge remote-tracking branch 'origin/studio' into upd-12037 2026-10-09 01:40:29 -07:00
yhh d6b2ef4de7 fix(compaction): restart the card's digest on retry, bound-test the retry, align docs
A text-less compaction_progress frame now tells a frontend the streamed
digest was discarded, so a retried summary does not append to the cut one.
The pressure effect test counts attempts as pairs with a larger retry cap
instead of capping raw request count. Adds a shared-window room test and
updates SPEC.md.
2026-10-09 01:36:07 -07:00
YHH 79cef29613 Merge pull request #12470 from esengine/docs/sponsor-packycode
docs(readme): add a sponsor slot for PackyCode
2026-10-09 01:27:24 -07:00
YHH 535c65d8d6 Merge pull request #12367 from KHG420/codex/studio-adopted-interceptors
fix(studio): keep adopted interceptors working after model switches
2026-10-09 01:26:59 -07:00
YHH 2ed7e86f14 Merge pull request #12456 from esengine/release-notes-2.33.0
docs(release): Studio 2.33.0 release notes
2026-10-09 01:15:44 -07:00
yhh 287370ca03 fix(compaction): retry a length-truncated summary once with a larger output cap
The summary request is capped at 16K output tokens and reasoning shares that
cap, so a model with no low reasoning tier can spend all of it and have the
digest discarded. Truncation is already a typed finish reason, so the host
retries once with the cap doubled (bounded by the model's configured output
ceiling) and fails with the cap and retry recorded when that is cut too.
2026-10-09 01:02:20 -07:00
yhh 0d80335152 fix(studio): icon-rail hover hints are no longer clipped or covered
The rail clipped itself to its animated width, which cut off the hints that
hang outside it, and shared a z-index with the workspace rail that paints
later. The rail now keeps its open width and slides off screen instead, and
sits one layer above the workspace rail.

Refs #12462
2026-10-09 00:45:03 -07:00
yhh 8fe0b30dc0 fix(receipt): keep explicit VCS writes counted and refuse links into the snapshot store
Only the walk skips VCS store entries. An explicit write into a VCS store is a
change like any other. The snapshot-store exclusion is a path convention for
de-noising, so a symlink anywhere on the way disables it.
2026-10-08 23:40:03 -07:00
yhh 23be1e3344 fix(receipt): drop the CACHEDIR.TAG exclusion
A marker file anyone can write, the model included, would shrink what the host
observes of a delivery. Only state the host itself writes is excluded.
2026-10-08 23:12:39 -07:00
yhh 49073a9648 fix(receipt): keep host-owned state and the English sentence off the delivery card
The unverified-change gap carried a sentence the kernel wrote in English, so
every language showed it; it now carries only the declined command. Changes the
host or a tool owns -- the task-snapshot store, VCS stores (including a .git
file), and directories tagged with CACHEDIR.TAG -- no longer count as work.

Refs #12457
2026-10-08 23:05:04 -07:00
YHH 5e17135b6f Merge pull request #12456 from esengine/release-notes-2.33.0
docs(release): Studio 2.33.0 release notes
2026-10-08 20:53:03 -07:00
YHH 24dee91241 Merge pull request #12450 from esengine/fix/feedback-sync-stable-tags
fix(feedback-sync): count only stable studio tags and merged closing PRs
2026-10-08 20:52:37 -07:00
yhh 4a13a49dc7 docs(release): Studio 2.33.0 release notes 2026-10-08 20:39:55 -07:00
YHH 471c40cff3 Merge pull request #12448 from esengine/perf/studio-idle-gpu
perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
2026-10-08 20:37:20 -07:00
YHH ffca8ca15d Merge pull request #12446 from esengine/chore/bump-go-and-x-net
chore(deps): bump Go toolchain to 1.26.9 and x/net to v0.60.0
2026-10-08 20:21:44 -07:00
YHH 60d26453c8 Merge pull request #12447 from esengine/fix/tui-input-complexity
refactor(tui): bring onKey back under the complexity budget
2026-10-08 20:06:45 -07:00
YHH ee742a31fd Merge pull request #12454 from esengine/fix/mcp-binding-linux-tests
test(mcp): make the enable-binding tests hold on Linux
2026-10-08 19:56:06 -07:00
esengine 37c4f3d744 test(mcp): make the enable-binding tests hold on Linux
The tests that fake Windows name probing on every OS spelled PATHEXT in
upper case while their fixture files are lower case. Windows file lookups
ignore case, so the real lookup finds mcp.cmd for .CMD; a Linux filesystem
does not, so under the fake the probed name missed the fixture and the
tests failed there. The seam fakes the probing, not the filesystem: spell
the extensions as the fixtures are.

Adds the off-Windows counterpart: where nothing probes extensions, a .cmd
beside the command does not affect the binding, and the command file does.
2026-10-08 19:39:56 -07:00
yhh 27f10eaf95 feat(studio): let the user switch hardware acceleration off and see how the window is drawn
The shell exposed no way to leave the GPU out of it. Chromium fixes its GPU
use before the app is ready, so a saved choice (rx-hw-accel, in the existing
window-prefs.json) is read right after the profile directory is set and
before ready; the shell reports what this launch applied and what Chromium
ended with through a new shell:graphics handle. Appearance gets a Graphics
block, shown only in the desktop shell, saying which mode this launch uses
and that a change applies after a restart.
2026-10-08 19:37:02 -07:00
yhh daa13a418a fix(feedback-sync): count only stable studio tags and merged closing PRs
A fixed entry is upgraded from next to a concrete version only when a merged
closing pull request is contained in a studio tag whose release page is neither
a draft nor a prerelease. Prerelease status is read from the releases API, not
from a version cutoff.
2026-10-08 19:32:30 -07:00
yhh 39e4f4033d perf(studio): stop a sky pack and frosted panels from keeping the GPU busy
A pack with a sky drew a blurred cloud bank under six backdrop-filter panels,
so every frame re-blurred 41% of the window and the idle window held a GPU
engine near full on integrated graphics. Frosted panels that are already
72-94% opaque blurred nothing visible, and the grain layer forced a
full-window overlay blend on every frame.

- Sky: CSS breathing animations removed; the canvas redraws on a 15 fps
  timer and only while the window is shown and focused (presence.ts).
- backdrop-filter kept on the composer and the transcript backing only;
  the other panels get a higher flat tint instead.
- Grain drops mix-blend-mode.
- New Appearance setting, Visual effects: Full / Reduced (rx-effects,
  immediate, per machine). Reduced removes remaining blur, text glow, the
  grain and the sky.
- perf/effects.mjs guards all of it in a real browser.
2026-10-08 19:30:48 -07:00
yhh 0cfa0484ce refactor(tui): move the steer and history-edge key rules out of onKey 2026-10-08 19:28:51 -07:00
yhh c8622f7a8c chore(deps): bump Go toolchain to 1.26.9 and golang.org/x/net to v0.60.0 2026-10-08 19:27:43 -07:00
YHH 178dbb7934 Merge pull request #12443 from esengine/feat/studio-open-html-preview
feat(studio): open an HTML file from the explorer as a page preview
2026-10-08 18:23:00 -07:00
YHH a59623aedc Merge pull request #12442 from esengine/feat/studio-model-picker-parity
feat(studio): group, search and mark the composer model picker
2026-10-08 18:21:51 -07:00
YHH e28b76734c Merge pull request #12437 from xxoingr/fix/tui-ctrl-enter-steer
fix(tui): Ctrl+Enter steers a running turn
2026-10-08 18:20:42 -07:00
YHH 5180a74008 Merge pull request #12436 from xxoingr/fix/tui-right-click-paste
fix(tui): right-click with no selection pastes clipboard text
2026-10-08 18:19:33 -07:00
YHH 02395fea16 Merge pull request #12435 from xxoingr/fix/tui-recall-multiline
fix(tui): history recall walks past a multi-line entry
2026-10-08 18:18:28 -07:00
YHH be021ac52f Merge pull request #12332 from KHG420/codex/studio-sent-image-previews
fix(studio): preview saved image attachments in user messages
2026-10-08 18:17:28 -07:00
YHH 172678e1cd Merge pull request #12429 from icebear02/fix/device-panel-relay-status
fix(studio): explain why Internet device access is unavailable
2026-10-08 18:16:24 -07:00
YHH 449f8ca219 Merge commit from fork
fix(mcp): bind a project server's enable to its declaration
2026-10-08 18:10:26 -07:00
yhh 0dee6fd100 feat(studio): open an HTML file from the explorer as a page preview
Clicking .html/.htm in the workbench explorer shows the file as a page in the
shared browser through the existing disk-path open, reloading when the file
changes on disk. Windows that draw no browser views and remote workspaces keep
the source editor.
2026-10-08 18:09:36 -07:00
yhh 6062a8d011 feat(studio): group, search and mark the composer model picker
The model menu now always opens with a search field, groups models under a
sticky provider heading with a letter chip, uses compact one-line rows and
grows with the window (up to 640px) instead of a short fixed box. Rows keep
the wire format and show a reads-images badge where the model declares it.
Enter no longer lands on the manage action, and an empty search says so.
2026-10-08 17:57:50 -07:00
esengine e2d8eba20d fix(mcp): bind a project server's enable to its declaration
An enable recorded for a server that .mcp.json or a project reasonix.toml
declares held for the server's name. The row now records a digest of the
declaration as written (type, command, args, env, url, headers), the project
.env values it expands, and the content of the workspace files it names:
its arguments, and every file the launcher could start for its command,
found by the launcher's own lookup (base/proc): the declaration's PATH for a
bare name (every Windows spelling of it, in key order), and PATHEXT
siblings on Windows, dotted names included. A different declaration reads
as ActivationChanged: off and awaiting the user. Rows written before this
carry no digest and ask once.

- Specs for a repository-declared server carry a LaunchCheck run before
  every start and redial; env overrides merge in key order.
- Why a server waits is a typed code (awaiting_user_decision,
  changed_since_enabled) on MCPHealth, the serve and ACP wires and the TUI;
  Studio and the TUI word it in the reader's language; Studio shows the
  launch line beside the switch.
- A connect or reconnect by name refuses a repository server in any pending
  state with ErrMCPApprovalOwed (serve: mcp.approval_owed) and its launch
  line; a server from the user's own files is still answered by a connect.
- Build reports each held project server once, so `reasonix run` prints
  one warning line per server; serve prints the same at startup; /mcp and
  `reasonix mcp list` show them. Repository-chosen names are sanitised
  wherever they are printed, and run diagnostics sanitise every line.
- `reasonix mcp enable` prints the approved launch line; a project install
  records the decision for the declaration as written; doctor --live
  starts only what a session would.
2026-10-08 12:09:24 -07:00
esengine 7ba591ad13 test(mcp): an enable covers the declaration and the files it runs
Every test for the project MCP approval change, ahead of the change:

- boot effect tests through the real assembly and the settings switch: a
  rewritten declaration, a launcher changed mid-session and an executable
  swapped behind the declaration's own PATH must not start on an earlier
  enable; held servers are reported once each at build; a repository's
  server name reaches no notice as control sequences.
- config: the enable covers the declaration, the .env values it expands,
  the files it names, the executable the launcher resolves (declared PATH,
  every spelling of PATH, PATHEXT siblings including dotted names), legacy
  rows ask again; base/proc holds the launcher's lookup.
- plugin: the launch check runs before start and redial; env merges in a
  fixed order.
- control/cli/tui/frontend: a connect by name never approves a repository
  server that waits; why it waits is a typed code on every surface; /mcp,
  `reasonix mcp list`, serve's startup warning, run diagnostics and the TUI
  panel print a hostile name as plain text; doctor --live starts only what
  a session would.
2026-10-08 12:09:24 -07:00
YHH a90594dda2 Merge pull request #12439 from esengine/fix/flaky-frontend-2
fix(studio): move focus into the feedback body after the environment has committed
2026-10-08 11:55:51 -07:00
YHH 87b385f86e Merge pull request #12438 from esengine/fix/flaky-frontend-1
test(studio): wait for the pane's subscription before emitting into it
2026-10-08 11:54:53 -07:00
yhh fa985108d7 fix(studio): move focus into the feedback body after the environment has committed
The form moved focus from the tab into the body in a frame callback queued from the environment promise. The fieldset is disabled until that environment is committed, so a frame that fired before the commit called focus() on a disabled textarea and focus stayed on the tab. The move now runs in an effect on the environment, after the commit.
2026-10-08 11:40:32 -07:00
yhh d29c170e54 test(studio): wait for the pane's subscription before emitting into it
The unread interaction tests emitted wire events through a listener map that was only filled once the pane subscribed, and a missing listener was a silent no-op. The first test of the turn-ending group waited for the port to exist, which is earlier than the subscription, so an event could be dropped under load. emit now waits for the listener.
2026-10-08 11:40:22 -07:00
xxoingr bf55a5221b fix(tui): Ctrl+Enter steers a running turn
docs/GUIDE.md lists Ctrl+Enter (or /steer) as the key that adds guidance
to the active turn, and 1.x bound it so. 2.x bound only Ctrl+S, so on a
terminal that reports Ctrl+Enter the documented key did nothing.

While a turn runs, Ctrl+Enter now sends the composer as steer through the
same path as Ctrl+S. Idle it does nothing, as in 1.x, so a press meant as
a newline never submits the draft. Ctrl+S is unchanged.

Refs #12144
2026-10-09 02:29:23 +08:00
xxoingr de3c6bdcd2 fix(tui): right-click with no selection pastes clipboard text
With in-app mouse capture on, a right-click copied an active transcript
selection and otherwise did nothing. 1.x pasted the clipboard's text on
a right-click with no selection, and docs/GUIDE.md and docs/CLI.md say
2.x does too, so the documented paste never happened.

A right-click with no selection now pastes through the same path as
Shift+Insert: text only, through the normal paste handling, with the
existing hint over SSH. It pastes only while the composer is drawn; the
condition that draws it moves into composerShown so the view and the
mouse read one answer. A right-click on a selection still copies it.

Refs #12144
2026-10-09 02:27:16 +08:00
xxoingr ea148fed50 fix(tui): history recall walks past a multi-line entry
Up and Down recalled history only while the composer held a single line.
Once a recalled entry had several lines, Up moved the caret to its first
line and then stopped: nothing older could be reached, and a multi-line
draft could not step into the history at all.

Up now recalls from the composer's first line and Down from its last, as
1.x did; between them the keys move the caret as before. A single line,
soft-wrapped or not, behaves exactly as it did.

Refs #12144
2026-10-09 02:24:54 +08:00
YHH 41e8f3be6f Merge pull request #12421 from xxoingr/fix/tui-mcp-panel-keys
fix(tui): q, h/l and Left/Right in the MCP manager
2026-10-08 11:11:48 -07:00
YHH 034c9519f4 Merge pull request #12409 from esengine/dependabot/npm_and_yarn/workers/crash-report/multi-1f96396017
chore(deps): bump tinypool and vitest in /workers/crash-report
2026-10-08 10:53:41 -07:00
YHH 0188c0a5c7 Merge pull request #12408 from esengine/dependabot/npm_and_yarn/workers/crash-report/source-map-js-1.2.2
chore(deps-dev): bump source-map-js from 1.2.1 to 1.2.2 in /workers/crash-report
2026-10-08 10:52:29 -07:00
YHH edc780dbd3 Merge pull request #12420 from xxoingr/fix/tui-ask-jkhl
fix(tui): j/k and h/l drive the question card
2026-10-08 10:37:48 -07:00
YHH 75ccd2abcd Merge pull request #12419 from xxoingr/fix/tui-approval-jk
fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
2026-10-08 10:22:02 -07:00
icebear02 4c459ad337 test(studio): cover empty cloud status reason 2026-10-09 01:08:05 +08:00
icebear02 d3d4c03452 fix(studio): preserve neutral empty cloud status 2026-10-09 01:07:33 +08:00
YHH b6cd0df288 Merge pull request #12423 from KHG420/codex/studio-feedback-image-read-lifetime
fix(studio): keep pending screenshots within their feedback draft
2026-10-08 10:07:21 -07:00
icebear02 12129ac460 refactor(remotecloud): classify relay dial errors
Refactor error handling in relay dial logic and extract into a new function.
2026-10-09 01:06:16 +08:00
xiexian aedbba6314 fix(studio): retain saved image previews after a failed load 2026-10-09 01:01:58 +08:00
YHH 72f596d209 Merge pull request #12411 from KHG420/codex/studio-queue-edit-read-order-preflight-r2
fix(studio): keep the latest queued edit selection
2026-10-08 09:52:37 -07:00
YHH c47bdfd847 Merge pull request #12432 from esengine/docs/ui-quality-process
docs: UI-change section in the PR template and a 1.x parity table
2026-10-08 09:47:52 -07:00
YHH 40bac6c3c4 Merge pull request #12344 from KHG420/codex/studio-pane-startup-reads
perf(studio): avoid duplicate reads before the session is known
2026-10-08 09:41:30 -07:00
YHH f2112e6ad8 Merge pull request #12426 from KHG420/codex/studio-resend-rewind-failure
fix(studio): preserve message edits when rewind fails
2026-10-08 09:38:50 -07:00
yhh a0a53ca890 docs: add UI-change section to the PR template and a 1.x parity table
Cause: 30 days of Studio UI changes landed without screenshots or review, and a visible control was hidden without a linked issue or a release note.
The PR template had no UI requirement and no table tracked 1.x features.
2026-10-08 09:25:40 -07:00
YHH d779d89bcc Merge pull request #12424 from esengine/docs/readme-plain-language
docs(readme): say what Reasonix does in plain words
2026-10-08 08:12:37 -07:00
YHH d4e7fe771a Merge pull request #12428 from esengine/feat/studio-nav-rail-modes
feat(studio): icon rail at the far left, with feedback and three display modes
2026-10-08 08:03:46 -07:00
icebear02 129743f52f test(repolint): cover the cloud remote status mirror 2026-10-08 22:56:06 +08:00
icebear02 6efb57191f i18n(studio): translate cloud relay status messages 2026-10-08 22:55:52 +08:00
icebear02 25d938b61a test(studio): cover typed cloud status messages 2026-10-08 22:55:36 +08:00
icebear02 8afc203738 fix(studio): explain unavailable Internet access 2026-10-08 22:55:21 +08:00
icebear02 421c32c8dd test(studio): seed the mock cloud reason 2026-10-08 22:54:59 +08:00
icebear02 8cdfffca62 fix(studio): type the cloud remote status reason 2026-10-08 22:54:40 +08:00
yhh 8429cd2708 feat(studio): icon rail at the far left, with feedback and three display modes
Refs #12427. The rail moves left of the sidebar, gains the feedback entry, and the Appearance setting becomes always / only when the sidebar is collapsed / never. Stored on and off values keep their meaning.
2026-10-08 07:53:34 -07:00
icebear02 d1dac77fb5 fix(serve): expose the cloud remote status reason 2026-10-08 22:50:28 +08:00
icebear02 1e2171c7a2 fix(studio): carry the cloud status reason to the share bridge 2026-10-08 22:50:04 +08:00
icebear02 53a8a82896 fix(remotecloud): classify unavailable relay states 2026-10-08 22:49:38 +08:00
yhh 46658f5508 docs(readme): lead with where it runs, then what it does in plain verbs 2026-10-08 07:22:47 -07:00
yhh 008aca42b6 docs(readme): rewrite the plain-language copy in a calmer, more concrete voice 2026-10-08 07:07:27 -07:00
yhh 8dbd6583d7 docs(readme): say the model is a configured service, not something that runs on your machine 2026-10-08 06:30:24 -07:00
xiexian ff48415d47 fix(studio): preserve message edits when rewind fails 2026-10-08 21:23:27 +08:00
yhh f1a8525c44 docs(readme): say what Reasonix does in plain words before the technical detail 2026-10-08 06:21:45 -07:00
xiexian 174228553a fix(studio): keep screenshot reads with their feedback draft 2026-10-08 20:43:48 +08:00
xiexian 29be7e29ec perf(studio): coalesce reads while initial status is pending 2026-10-08 20:32:16 +08:00
xxoingr 55476745c4 fix(tui): q, h/l and Left/Right in the MCP manager
The /mcp panel opened a server's tools with Enter and backed out with
Esc only. 1.x's MCP manager also went in with l / Right, backed out one
level with h / Left, and closed from anywhere with q, and docs/GUIDE.md
(both languages) still lists those keys, so pressing them did nothing.

The panel now takes l and Right as Enter, h and Left as Esc (back to the
list, or closed from the list), and q closes it from the list or a
server's tools. The repository-declared confirm line is unchanged: any
key but y still cancels it.

Refs #12144
2026-10-08 19:45:53 +08:00
xxoingr bd439b77ef fix(tui): j/k and h/l drive the question card
The ask card took only the arrows: Up/Down (and Tab) to move between
rows, Left/Right to move between questions. 1.x's question card also
took j/k and h/l, and docs/GUIDE.md (both languages) lists them for it,
so pressing them on 2.x did nothing.

The card now takes k as Up, j as Down, h as Left and l as Right, with the
existing wrap, clamp and auto-submit stop. A typed answer or note is
unaffected: the composer gets those keys as text before the card does.

Refs #12144
2026-10-08 19:43:08 +08:00
xxoingr cb755dac33 fix(tui): j/k and Ctrl+N/Ctrl+P move the approval cursor
The approval card moved its cursor with the arrows and Tab only. 1.x also
took j/k and Ctrl+N/Ctrl+P there, and docs/CLI.md says approval rows use
the same row-selection keys as the pickers, so pressing them did nothing.

The card now takes k and Ctrl+P as Up and j and Ctrl+N as Down. None of
them is a row shortcut (y/a/p/n/x), so no answer changes meaning.

Refs #12144
2026-10-08 19:40:00 +08:00
dependabot[bot] aa139c9f15 chore(deps): bump tinypool and vitest in /workers/crash-report
Removes [tinypool](https://github.com/tinylibs/tinypool). It's no longer used after updating ancestor dependency [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Removes `tinypool`

Updates `vitest` from 3.2.7 to 5.0.3
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.3/packages/vitest)

---
updated-dependencies:
- dependency-name: tinypool
  dependency-version:
  dependency-type: indirect
- dependency-name: vitest
  dependency-version: 5.0.3
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:31:17 +00:00
dependabot[bot] b128ecb2d2 chore(deps-dev): bump source-map-js in /workers/crash-report
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-10-08 10:30:55 +00:00
xiexian 940d193d26 fix(studio): keep the latest queued edit selection 2026-10-08 18:25:39 +08:00
f1shsmell ca9de5c0d9 chore(repolint): declare six look.ts wire mirrors
Every type the desktop re-declares by hand is held to the kernel's, and
src/port/look.ts was the one file under port/ that no pair named. The theme
a person is looking at is all of it: the pack listing, the pack's two shape
blocks, the import receipt, and the reader's size and wallpaper.

Declared and not inferred: no spelling tells a mirrored contract from a
struct that merely carries json tags.
2026-10-08 16:38:58 +08:00
Guan09 f0c4d0828f fix(checkpoint): sync file state after code rewind to prevent false conflicts 2026-10-08 14:47:03 +08:00
xiexian 5da56c79dd fix(boot): bind switched interceptors to adopted sidecars 2026-10-08 12:59:32 +08:00
Will ad85f2707f feat(memory): report omitted matches on the memory tool paths
Search and list dropped results silently: hits below the relative score
floor (KeepTopRelativeScore compacts in place, taking the count with it)
and everything past limit. A reader could not tell a complete result from
a truncated one, while the empty-result message already warns that no
result is not proof of absence.

Both paths now carry the note automatic recall already uses for its own
omissions, counting both reasons and staying silent when nothing was
dropped. The spelling lives in one helper the recall block calls too, so
the two cannot drift apart; its output is unchanged. The count is one
function as well, so the case where both reasons fire is tested directly
instead of only through fixtures. Empty results keep their guidance: the
relative floor always keeps the top hit, so empty still means nothing
matched.

Refs #11853.
2026-10-08 11:02:07 +08:00
drakeo338 d651569af7 fix(config): keep the config edit lock directory per user and refuse a foreign owner
The lock directory sat at a shared, predictable /tmp path, so another
identity could create it first and every later run by this user failed
on os.Chmod with a bare EPERM.

Put it under the OS user's home (from the account database, so HOME and
TMPDIR overrides do not move it), as Windows already did. Keep the owner
uid check before the chmod as a backstop: it returns ErrLockDirWrongOwner,
naming the path and uid, without touching the directory. The /tmp name
is only a fallback when no home is recorded. The check is a no-op on
Windows; lockdir_owner_other.go is renamed lockdir_owner_windows.go.

Fixes #11372
2026-10-06 15:08:47 +00:00
xiexian 6386cd91c9 fix(studio): preserve memory drafts and pending writes 2026-10-06 18:18:05 +08:00
xiexian f01e71ccfc fix(installsource): verify registered skills by source identity 2026-10-06 13:33:30 +08:00
xiexian 6166ba143f fix(installsource): attribute skill discovery to its installed path 2026-10-06 13:24:35 +08:00
xiexian b2628325b7 fix(studio): scope package installation forms to connection 2026-10-06 08:58:18 +08:00
xiexian c76e155243 docs(studio): run scenario exercises from installed packages 2026-10-05 21:40:48 +08:00
yhh cbadfe1753 feat(compaction): announce an automatic compaction whose retry is held after a failed attempt
When a summary fails under the pressure trigger the retry is held, and
while it is held and compaction is due nothing reached the user. The
agent now emits a coded notice, compact_held, once per turn and cause,
with the failure's code as detail. The desktop and the TUI word it from
the code in the UI language; a code a build cannot word keeps the
kernel's English.
2026-10-05 02:13:15 -07:00
yhh 95c63e6602 fix(installsource): bound and sanitise package-supplied preview text
Project every string and list of an install plan through one bounded
projection shared by all frontends: names, paths, commands and arguments
render hidden characters as visible escapes, prose has them removed, and
each field, list and the action count has a cap. A cut ends the field with
an ellipsis and sets previewTruncated on the action and the plan. The planId
still digests the complete unprojected plan, and the market ledger records
the unprojected paths. Installed-package detail views (inventory, record,
runtime, warnings) use the same limits from the shared text utility.
2026-10-04 21:03:30 -07:00
yhh 16933b79d4 fix(docs): keep the owner, aliases and tool identities out of the acknowledgments table 2026-10-04 20:47:45 -07:00
esengine 7aed16a34f feat(desktop): one declared interface-scale range, and Ctrl/Cmd +/-/0 to change it
The interface scale was declared four ways: the config comment said
0.8..1.6, the save clamped to 0.7..1.8, the slider ran 0.8..2.5 and the
presets covered 0.9..1.3. Dragging the slider to 2.0 showed 2.0 while the
kernel stored 1.8.

config.ZoomMin/ZoomMax/ZoomStep are now the only declaration. The save
clamp, the rendered config comment and the range /appearance announces
(mirrored to the page by wire-parity) all read them, and the slider is
drawn from the announced range instead of carrying its own. A stored
value outside the range reads as the nearest bound but is not rewritten
unless the user changes the zoom.

Ctrl/Cmd + '=' / '+' / '-' / '0' (read by key or by key position, so
shifted and non-QWERTY layouts work) step the interface scale through the
named presets (range ends outside them) and reset to standard, saving
through the same appearance setting as the Appearance control. Chromium
page zoom is not used.

The range is 0.8..1.8: 1.8 was the largest value the old save could
store, so nobody loses headroom. The keys exist only inside the desktop
shell; a browser tab keeps its own page zoom.

Fixes #12032
Part of #11962
2026-10-04 20:42:20 -07:00
YHH 4ffed1b50c Merge branch 'studio' into feat/studio-sponsor 2026-10-03 18:47:02 -07:00
esengine 69e78746dd feat(studio): add a Support entry with PayPal and payment QR codes to About 2026-10-02 04:12:32 -07:00
esengine c74ea3c431 feat(studio): opt-in archiving of conversations inactive for N days
Adds a user-level [auto_archive] setting (enabled = false, days = 30 by
default; a project reasonix.toml cannot set it). While on, the controller's
ArchiveInactiveSessions archives conversations idle past the threshold from
the same background pass that sweeps recovery branches, so it stays off the
boot path. Archiving only flips catalog metadata and is reversible.

Never archived: pinned, leased by a live run (covers a run or a pending
answer/approval), carrying an unfinished turn, the controller's own
session, subagent traces and superseded versions. Restoring a conversation
counts as activity so it is not re-archived straight away.

The kernel is the authority for pins (BranchMeta.Pinned, tree rows carry
`pinned`). The window sends the union of its own pins and the kernel's
after the tree loads and retries on failure; the sweep, including the one
after enabling, refuses to run until a window has synced once.

Archived conversations stay findable: search returns them with an
"archived" marker, and rows archived by the sweep carry a mark so the
window says once how many were archived and that they can be restored.
Settings > Session gets GET/POST /auto-archive with persistence to the
user file and zh/en strings.

Refs #11516
2026-10-01 04:30:46 -07:00
esengine a26c36df13 feat(feedback): keep the unread badge fresh and announce new replies
Refresh the feedback unread count on window focus (at most every 30 s) and
every 10 minutes while a report is unsettled, with back-off to 1 h and no
error UI. A person with no open report is never polled. A hidden window keeps
polling only for someone who enabled the new feedback-reply notification.

Add a fourth notification kind, feedback_reply (default on under the master
switch), raised through POST /notifications/feedback-reply when the unread
count rises while the window is unfocused. The text never carries the reply.
2026-10-01 00:05:59 -07:00
1250 changed files with 57402 additions and 9256 deletions

No files matched your search

+12
View File
@@ -24,6 +24,18 @@ only, on `main-v2`.
-
## UI changes
<!--
Delete this section if the PR touches nothing under `desktop/frontend-next/src`
or the website pages.
-->
- Screenshots, before and after: light and dark, a wide window and a phone-width one (about 390 px). Attach them to this description.
- Entries moved, hidden or removed: none / list each one.
- Hiding or removing a visible control needs a linked issue and a release-notes line naming it. Do not retire UI with `display: none !important` or a feature flag.
- If a row of `docs/STUDIO_PARITY.md` changes, update it here.
## Issues
<!--
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #ffffff;
stroke: #ffffff;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="_图层_2" data-name="图层 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 145.55 113.29">
<defs>
<style>
.cls-1 {
fill: #111111;
stroke: #111111;
stroke-miterlimit: 10;
}
</style>
</defs>
<g id="_图层_1-2" data-name="图层 1">
<g>
<path class="cls-1" d="M144.68,38.49l-.06-.23c-.88-3.28-2.5-5.94-4.58-8.06.14,5.65-2.96,11.02-6.22,16.66l-.39.68c-2.26,3.91-4.66,7.94-6.57,11.1l-.86,1.38c-3.36,5.44-6.27,10.14-12.18,14.18-8.34,5.87-18.2,5.81-26.92,5.76-2.81-.02-5.48-.03-7.95.14l-.35.02c-3.22.06-5.96,1.57-8.17,4.49l-.14.18c-.86,1.06-1.7,2.26-2.58,3.54-3.43,4.92-7.69,11.04-16.17,12.42-4.37.86-9.98.84-14.94.83h-1.95c-.52,0-1.06,0-1.61,0-6.95,0-16.08-.89-21.94-6.55-.15-.15-.3-.3-.44-.45.5,4.73,2.33,8.64,5.44,11.65,5.86,5.66,14.98,6.55,21.94,6.55.55,0,1.09,0,1.61-.01h1.93c4.96.02,10.58.04,14.96-.83,8.48-1.37,12.74-7.5,16.17-12.42.88-1.27,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.43,8.17-4.49l.35-.02c2.47-.17,5.13-.16,7.95-.14,8.72.05,18.58.11,26.91-5.76,5.92-4.03,8.82-8.73,12.19-14.17l.86-1.39c1.91-3.15,4.3-7.18,6.57-11.09l.39-.69c3.81-6.6,7.41-12.84,5.86-19.57ZM120.9,23.02c-.28,0-.56,0-.83,0-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.41.69-.77,1.3-1.09,1.84l-.49.84c-1.49,2.53-3.23,5.49-4.21,8.95,3.98,1.9,8.52,2.65,12.71,2.91.27-.77.64-1.56,1.07-2.38.48-.94,1.05-1.9,1.63-2.89l.49-.84c.96-1.62,2.38-3.99,4.05-6.78,3.82-6.36,8.98-14.88,13.19-21.55l.07-.11c5.02-8.31,9.2-13.45,16.91-16.81l.11-.05c6.57-2.7,10.54-2.74,18.43-2.81.87-.01,1.78-.02,2.69-.03l1.99-.02c9.17-.06,20.13-.14,29.01-.07,2.26.01,4.39.04,6.32.08h.12s.11,0,.11,0c1.25-.01,2.91.11,4.61.46,1.03.22,2.08.52,3.05.93.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82-4.08-1.92-8.7-2.76-12.89-2.82Z"/>
<path class="cls-1" d="M115.07,11.81c-9.7-.19-24.07-.09-35.62-.01h-1.99c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.13,9.71-14.19,23.21-17.41,28.63l-.5.84c-2.08,3.55-4.68,7.97-4.94,13.39v.2s0,.21,0,.21c.01.81.06,1.61.15,2.38.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-5.18-5.29-13.22-7.25-19.97-7.19ZM122.56,40.37l-.39.67c-2.21,3.81-4.55,7.77-6.39,10.79l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.95,3.45-19.38,3.41-2.88-.02-5.87-.04-8.79.16-7.1.19-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.28.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-2.24-.48-3.87-1.22-4.89-2.2-.66-.64-1.54-1.81-1.63-4.65.11-1.35.71-2.82,1.52-4.35.48-.94,1.05-1.9,1.63-2.89l.49-.84c3.17-5.33,11.19-18.75,17.24-28.33l.07-.11c5.02-8.32,9.2-13.45,16.92-16.81l.1-.05c6.57-2.7,10.54-2.74,18.43-2.82.87,0,1.78,0,2.69-.03h1.94c11.52-.09,25.86-.19,35.38,0h.23c1.25-.01,2.92.11,4.61.46,3.15.66,6.4,2.12,7.27,5.02.2,1.2-.89,3.62-2.27,6.18-.7,1.31-1.48,2.65-2.2,3.9ZM120.07,23.01c-9.68-.19-24.03-.09-35.57-.01l-2.04.02c-.93.01-1.82.02-2.67.03-8.36.08-14.4.13-23.33,3.82l-.27.12c-10.76,4.68-16.91,12.16-22.83,21.95-5.53,8.76-12.62,20.57-16.32,26.79-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.28,0-.56,0-.83,0Z"/>
<path class="cls-1" d="M134.68,16.09l-.06-.23c-3.07-11.45-15.08-15.33-24.55-15.25-9.68-.19-24.03-.09-35.57-.01h-2.04c-.93.03-1.82.04-2.67.04-8.36.08-14.4.14-23.33,3.83l-.27.12c-10.76,4.67-16.91,12.16-22.83,21.95-6.14,9.73-14.2,23.22-17.41,28.62l-.49.85c-2.09,3.55-4.69,7.96-4.95,13.39v.2s0,.21,0,.21c.09,5.57,1.82,10.13,5.15,13.58.14.15.29.3.44.45,1.53,1.47,3.28,2.63,5.15,3.52,3.98,1.9,8.52,2.65,12.71,2.91,1.41.09,2.78.12,4.08.12.55,0,1.09-.01,1.61-.01h1.95c4.95.01,10.57.03,14.94-.83,8.48-1.38,12.74-7.5,16.17-12.42.88-1.28,1.72-2.48,2.58-3.54l.14-.18c2.21-2.92,4.95-4.44,8.17-4.49l.35-.02c2.47-.17,5.14-.16,7.95-.14,8.71.05,18.58.1,26.92-5.76,5.91-4.04,8.82-8.74,12.18-14.18l.86-1.39c1.74-2.86,3.87-6.45,5.95-10.03.21-.35.41-.71.62-1.06l.39-.68c1.92-3.33,3.79-6.57,4.97-9.82.82-2.26,1.31-4.53,1.25-6.84-.02-.96-.14-1.93-.36-2.91ZM119.76,25.27c-.7,1.31-1.48,2.65-2.2,3.9l-.39.67c-1.19,2.04-2.41,4.13-3.57,6.09-1.01,1.7-1.97,3.31-2.82,4.7l-.84,1.36c-3.01,4.87-4.83,7.81-8.48,10.29l-.1.07c-4.94,3.49-11.96,3.45-19.38,3.41-2.89-.02-5.87-.04-8.79.16-7.1.18-13.51,3.58-18.07,9.57-1.13,1.4-2.12,2.83-3.08,4.21-2.92,4.18-4.71,6.57-7.64,7.02l-.31.06c-3.09.63-8.27.61-12.45.6h-2.16c-3.85.07-7.01-.16-9.45-.69-1.16-.25-2.16-.57-2.99-.96-.78-.35-1.41-.77-1.9-1.24-.66-.64-1.54-1.81-1.63-4.65.18-2.18,1.62-4.64,3.15-7.25l.49-.83c3.17-5.32,11.18-18.73,17.24-28.33l.07-.12c5.02-8.31,9.2-13.45,16.92-16.81l.1-.04c6.57-2.7,10.54-2.74,18.43-2.82.87-.01,1.78-.01,2.69-.03h1.99c11.5-.09,25.82-.19,35.33,0h.23c3.57-.04,10.55,1.02,11.88,5.48.14.84-.35,2.27-1.13,3.93-.34.72-.73,1.48-1.14,2.25Z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+48 -28
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/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
@@ -179,12 +186,9 @@ jobs:
# budget above normal 7-9 minute runner variance; each package test binary
# remains independently bounded by Go's per-package timeout below.
#
# That timeout is 12m, not 3m: across four consecutive runs of the same
# code, internal/assembly/boot measured 133s, 146s, 162s and then hit 180s, and
# internal/runtime/agent 82s, 92s, 144s and 180s — a 2.2x spread from runner
# variance alone, with four heavy packages sharing four vCPUs. The boot package
# has since grown to 422s and then ran past 480s on two consecutive runs of the
# same code, so 8m no longer leaves room. A hang still trips the job budget.
# The per-package timeout is 18m: internal/assembly/boot took 478-717s
# (12 push runs) on the Windows runner, so 18m is 1.5x the worst. A hang
# still trips the step budget (25m smoke, 35m full).
#
# Every root-module `go test` that runs on Windows passes -count=1. With
# the result cache on, go test records each file and environment access
@@ -195,11 +199,11 @@ jobs:
# internal/base/testenv holds every such step to it.
- name: test (Windows smoke)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name == 'pull_request'
timeout-minutes: 20
timeout-minutes: 25
env:
REASONIX_RELEASE_CACHE_GUARD: "1"
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
run: go test -count=1 -p 4 -timeout=18m ./internal/runtime/agent/... ./internal/platform/appidentity/... ./internal/assembly/boot/... ./internal/state/checkpoint/... ./internal/frontend/cli/... ./internal/session/control/... ./internal/base/filelock/... ./internal/base/fileutil/... ./internal/ext/hook/... ./internal/state/instruction/... ./internal/ext/mcplaunch/... ./internal/platform/notify/... ./internal/base/proc/... ./internal/platform/remote/... ./internal/platform/repair/... ./internal/safety/sandbox/... ./internal/base/sysproxy/... ./internal/state/workspacelease/... ./cmd/...
# The general Windows PR smoke list intentionally omits internal/contract/tool.
# Keep the session-temp portability contract covered without widening the
@@ -218,14 +222,12 @@ jobs:
timeout-minutes: 5
run: go test -count=1 -timeout=3m -run '^TestBashPowerShellExecuteDetailedContract$|^TestBashPowerShell51PreflightRejectsAndAndDetailed$|^TestBashPowerShellOutputIsUTF8$|^TestBashPowerShellSurfacesNonZeroExit$|^TestBashPowerShellRejectsChaining$' ./internal/tools/builtin
# Full push suite still uses the same 12m package budget as Windows smoke:
# agent/boot/control already hit 180s package timeouts under runner load,
# so 3m fails on slow machines without a code regression. It runs with
# -count=1 for the reason given at the smoke step. A hang still dies here
# rather than on a package.
# Full push suite uses the same 18m package timeout as Windows smoke and a
# 35m step budget above it. -count=1 as at the smoke step. A failure prints
# the failed tests' output and the 15 slowest boot tests.
- name: test (full)
if: env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
timeout-minutes: 30
timeout-minutes: 35
env:
# Run the prompt-cache prefix-stability guard (TestCacheHit*) in CI: a
# regression there silently tanks the cache hit rate the project is
@@ -234,7 +236,25 @@ jobs:
# Bound sandbox helper children in Windows tests so a failed OS-level
# launch cannot pin the Actions step after Go's package timeout fires.
WINDOWS_SANDBOX_WAIT_MS: "20000"
run: go test -count=1 -p 4 -timeout=12m ./...
shell: bash
run: |
go test -count=1 -p 4 -timeout=18m -json ./... | tee "$RUNNER_TEMP/full.jsonl" |
jq -rj --unbuffered 'select(.Action == "output" and .Test == null) | .Output'
- name: test (full) failure detail
if: failure() && env.RUN_STEPS == 'true' && runner.os == 'Windows' && github.event_name != 'pull_request'
shell: bash
run: |
f="$RUNNER_TEMP/full.jsonl"
[ -s "$f" ] || exit 0
echo "::group::output of failed tests"
jq -rn '[inputs | select(.Test != null)] | group_by(.Package + "/" + .Test)
| map(select(any(.[]; .Action == "fail")))[]
| map(select(.Action == "output") | .Output) | join("")' "$f" || true
echo "::endgroup::"
echo "slowest internal/assembly/boot tests (seconds, name):"
jq -rn '[inputs | select((.Package | endswith("/internal/assembly/boot")) and .Test != null and .Action == "pass")]
| sort_by(-.Elapsed)[:15][] | "\(.Elapsed)\t\(.Test)"' "$f" || true
race:
needs: changes
@@ -289,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
@@ -396,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:
@@ -424,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:
@@ -534,7 +554,7 @@ jobs:
run: go test -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7
with:
name: coverage-report
path: coverage.out
+3 -3
View File
@@ -43,20 +43,20 @@ jobs:
if: matrix.branch != ''
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Initialize CodeQL
uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.language == 'go' && 'autobuild' || 'none' }}
- name: Perform CodeQL Analysis
if: matrix.branch == ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
# Results from a checked-out line are filed under that line's ref, not the
# default branch the schedule happened to run from.
- name: Perform CodeQL Analysis (${{ matrix.branch }})
if: matrix.branch != ''
uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3
with:
category: "/language:${{ matrix.language }}"
ref: refs/heads/${{ matrix.branch }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
environment: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
cache: npm
+1 -1
View File
@@ -28,7 +28,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
+1 -1
View File
@@ -55,7 +55,7 @@ jobs:
fetch-depth: 0
persist-credentials: false
- if: steps.gate.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Sync
+37 -36
View File
@@ -202,7 +202,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -210,12 +210,13 @@ jobs:
desktop/frontend-next/pnpm-lock.yaml
desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb,
# and carries its own NSIS -- the toolchain the Wails line installed by
# hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is not this shell's.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm, and carries its own NSIS -- the toolchain the
# Wails line installed by hand (GTK, WebKitGTK, nfpm, a pinned NSIS) is
# not this shell's.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
# The same Developer ID and notary credentials the desktop line uses: one
# certificate signs any bundle identifier its team owns, so Studio does not
@@ -363,7 +364,7 @@ jobs:
# run nothing but the signer, and windows-package packages in between.
- name: Upload the unsigned Windows bundle for signing
if: runner.os == 'Windows' && env.SIGN_WINDOWS == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: unsigned-studio-windows-${{ matrix.goarch }}-bundle
path: dist/${{ matrix.unpacked }}
@@ -396,7 +397,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.goarch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: runner.os != 'Windows' || env.SIGN_WINDOWS != 'true'
with:
name: ${{ matrix.artifact }}
@@ -478,7 +479,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: unsigned-studio-windows-${{ matrix.arch }}-bundle
path: signing/app
@@ -533,7 +534,7 @@ jobs:
-ExpectedSubject $env:EXPECTED_SUBJECT
"digest=$(Get-Content -Raw "$env:RUNNER_TEMP/payload-digest")" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: signing/app
@@ -575,7 +576,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -587,7 +588,7 @@ jobs:
run: pnpm install --frozen-lockfile
# The signed payload is the whole bundle, so it is packaged as it came.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: dist/${{ matrix.unpacked }}
@@ -613,7 +614,7 @@ jobs:
wait "$zip_pid"
mv ../../dist-zip/ReasonixStudio-windows-${{ matrix.arch }}.zip ../../dist/
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: |
@@ -648,12 +649,12 @@ jobs:
ref: ${{ github.workflow_sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: signed-studio-windows-payload-${{ matrix.arch }}
path: payload
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -731,7 +732,7 @@ jobs:
}
if ($failed) { exit 1 }
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: packaged-studio-windows-${{ matrix.arch }}
path: dist
@@ -794,7 +795,7 @@ jobs:
$hash = (Get-FileHash -Algorithm SHA256 -LiteralPath $installer).Hash.ToLowerInvariant()
"installer-sha256=$hash" >> $env:GITHUB_OUTPUT
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist/ReasonixStudio-*
@@ -831,13 +832,13 @@ jobs:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-${{ matrix.arch }}
path: dist
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-${{ matrix.arch }}
@@ -911,7 +912,7 @@ jobs:
[ -f "dist/$want" ] || { echo "::error::no dist/$want"; exit 1; }
done
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: cli-archives
path: dist/*
@@ -970,28 +971,28 @@ jobs:
# Every package by its exact name, never a pattern: any job without
# secrets can upload an artifact, and a pattern would publish it. Windows
# comes from the installer signing job when it ran, else the build leg.
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-amd64
path: staging/studio-build-darwin-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-darwin-arm64
path: staging/studio-build-darwin-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: studio-build-linux-amd64
path: staging/studio-build-linux-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result == 'success'
with:
name: studio-windows-signed-arm64
@@ -1034,19 +1035,19 @@ jobs:
check "$dir" "ReasonixStudio-windows-$arch.zip" "${!archive}"
done
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-amd64
path: staging/studio-windows-amd64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.windows-sign-installer.result != 'success'
with:
name: studio-build-windows-arm64
path: staging/studio-windows-arm64
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: staging/cli-archives
@@ -1068,12 +1069,12 @@ jobs:
find staging/cli-archives -type f -print0)
ls -la dist
# Two Windows artifacts (installer, portable zip) per architecture,
# two per macOS architecture (dmg, zip) and the Linux .deb. Fewer
# two per macOS architecture (dmg, zip) and the Linux .deb and .rpm. Fewer
# means an artifact was lost between jobs, and a partial release is
# worse than a failed one.
count=$(find dist -type f -name 'ReasonixStudio-*' | wc -l)
if [ "$count" -lt 9 ]; then
echo "::error::expected 9 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb), found $count"
if [ "$count" -lt 10 ]; then
echo "::error::expected 10 artifacts (Windows installer + zip per architecture, macOS dmg + zip per architecture, Linux .deb and .rpm), found $count"
exit 1
fi
# Each platform's update channel, and there is exactly one per
@@ -1086,7 +1087,7 @@ jobs:
# the version it is running with nothing saying so.
# Each Windows architecture by name for the same reason: a glob passes
# on whichever installer shipped.
for required in 'ReasonixStudio-linux-*.deb' \
for required in 'ReasonixStudio-linux-*.deb' 'ReasonixStudio-linux-*.rpm' \
'ReasonixStudio-windows-amd64-installer.exe' 'ReasonixStudio-windows-arm64-installer.exe' \
'ReasonixStudio-windows-amd64.zip' 'ReasonixStudio-windows-arm64.zip' \
'ReasonixStudio-darwin-amd64.zip' 'ReasonixStudio-darwin-arm64.zip'; do
@@ -1182,8 +1183,8 @@ jobs:
fi
{
printf '%s\n\n' '---'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** 从 \`.deb\` 安装。\n\n" "$windows"
printf '%s\n' '三个平台都能自更新:Linux 走自己的包,Windows 运行下一个安装器,macOS 替换自身 bundle。'
printf "**macOS** 已用 Developer ID 签名并公证,打开时不需要清除隔离属性。%s**Linux** Debian 系从 \`.deb\` 安装,Fedora、RHEL、openSUSE 等从 \`.rpm\` 安装(\`sudo dnf install ./ReasonixStudio-linux-amd64.rpm\`)。\n\n" "$windows"
printf "%s\n" "三个平台都能自更新:Linux 的 \`.deb\` 走自己的包(\`.rpm\` 的自更新尚未提供,请手动下载新包),Windows 运行下一个安装器,macOS 替换自身 bundle。"
} >> "$notes"
assets=()
for f in dist/*; do
@@ -1403,7 +1404,7 @@ jobs:
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
registry-url: https://registry.npmjs.org
@@ -1412,7 +1413,7 @@ jobs:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.resolve.outputs.version }}
run: node npm/build.mjs "$VERSION" --publish
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
if: needs.resolve.outputs.candidate != 'true'
with:
name: cli-archives
@@ -1466,7 +1467,7 @@ jobs:
with:
ref: ${{ needs.resolve.outputs.sha }}
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: cli-archives
path: cli-archives
+1 -1
View File
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: '22'
- name: Test sweeper
@@ -46,7 +46,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -99,7 +99,7 @@ jobs:
-c.extraMetadata.version="${{ steps.version.outputs.version }}"
sha256sum ../../dist/ReasonixStudio-windows-amd64-installer.exe | tee -a "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: studio-selftest-windows-${{ steps.version.outputs.version }}
path: dist/ReasonixStudio-windows-amd64-installer.exe
+95 -15
View File
@@ -33,7 +33,7 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -114,7 +114,7 @@ jobs:
run_install: false
- if: steps.spa.outputs.run == 'true'
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
@@ -138,10 +138,20 @@ jobs:
node perf/intrinsic.mjs
node perf/contrast.mjs
node perf/wheel.mjs
node perf/branch-tooltip.mjs
node perf/selfcheck.mjs branch-tooltip
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
# moves the Windows and macOS legs onto an ubuntu runner with every step
# skipped, so the check names still report without a scarce runner. A push to
# studio, or a failed `scope`, runs all three.
desktop:
name: desktop (${{ matrix.os }})
runs-on: ${{ matrix.os }}
needs: scope
if: always()
runs-on: ${{ github.event_name == 'pull_request' && needs.scope.outputs.platform == 'false' && matrix.os != 'ubuntu-22.04' && 'ubuntu-latest' || matrix.os }}
env:
RUN_STEPS: ${{ github.event_name != 'pull_request' || needs.scope.outputs.platform != 'false' || matrix.os == 'ubuntu-22.04' }}
strategy:
fail-fast: false
matrix:
@@ -150,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
@@ -165,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
@@ -172,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
@@ -181,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:
@@ -198,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
@@ -210,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
@@ -248,16 +271,17 @@ jobs:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: desktop/electron/pnpm-lock.yaml
# electron-builder shells out to dpkg-deb through fakeroot for the .deb.
# electron-builder shells out to dpkg-deb through fakeroot for the .deb and
# to rpmbuild for the .rpm.
- name: Install Linux packaging deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y fakeroot
run: sudo apt-get update && sudo apt-get install -y fakeroot rpm
- name: Build the SPA
working-directory: desktop/frontend-next
@@ -312,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
@@ -367,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
@@ -414,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
@@ -431,6 +486,31 @@ jobs:
exit 1
fi
# The .rpm is read back for the same two properties as the .deb: it is the
# package update.StudioLine names, and the helper pkexec runs as root is
# owned by root and writable by no one else, or any local user could
# replace it.
- name: The .rpm is Studio's own package and carries what updates it
if: runner.os == 'Linux'
run: |
rpm=$(ls dist/ReasonixStudio-linux-*.rpm)
name=$(rpm -qp --qf '%{NAME}' "$rpm")
if [ "$name" != "reasonix-studio" ]; then
echo "::error::the package is $name; rpm holds that beside the installed reasonix-studio instead of upgrading it"
exit 1
fi
rpm -qp --qf '[%{FILEMODES:perms} %{FILEUSERNAME} %{FILEGROUPNAME} %{FILENAMES}\n]' "$rpm" > contents.txt
if ! grep -qxE -- '-rwxr-xr-x root root /usr/lib/reasonix-studio/reasonix-studio-update-helper' contents.txt; then
echo "::error::no root-owned, executable, not-writable update helper at the path the polkit action names"
grep reasonix-studio-update-helper contents.txt || echo "(not in the package at all)"
exit 1
fi
if ! grep -qE -- '^-r[w-][x-]r-[x-]r-[x-] root root /usr/share/polkit-1/actions/io.reasonix.studio.update.policy$' contents.txt; then
echo "::error::the polkit action is missing, or is not a root-owned file no one else can write"
grep polkit contents.txt || echo "(not in the package at all)"
exit 1
fi
# The Electron shell sets the package's glibc floor; a Go binary linked
# with cgo would raise it to the runner's own.
- name: The .deb's Go binaries are statically linked
@@ -478,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
+3 -3
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
@@ -33,6 +33,6 @@ jobs:
body: |
Refreshes the README acknowledgments tables from the GitHub contributors API.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=20&anon=1`
- Anonymous contributors are shown without email addresses.
- Source: `repos/esengine/DeepSeek-Reasonix/contributors?per_page=50&anon=1`
- The repository owner, declared aliases, tool identities, bots and entries without a GitHub login are left out (`scripts/credit-identities.json`).
labels: documentation
+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
+7
View File
@@ -198,6 +198,13 @@ in the PR. Documentation-only and test-only PRs are reviewed at lower priority.
- Every PR carries the four sections of the PR template: Cause, Blast radius,
Neighbouring behaviours tested, Why this layer.
- The new test must fail without the change.
- A UI change (`desktop/frontend-next/src` or the website pages) fills the
template's UI section: screenshots before and after, light and dark, wide
and phone width.
- The same section carries the line "Entries moved, hidden or removed".
- Hiding or removing a visible control needs a linked issue and a release-notes
line naming it, and updates `docs/STUDIO_PARITY.md` for a 1.x feature.
- Review holds these; no check reads them.
**Transparency.** The PR template has an optional checkbox for disclosing AI
assistance. It is for transparency only and does not change how a change is
+46 -8
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>Open source · MIT · a single Go binary</strong></p>
<h3 align="center">A reliable coding agent for complex software engineering tasks.</h3>
<p align="center">One local engine, four ways in — terminal, desktop app, browser, or your editor over ACP. Plan mode, permissions, a workspace sandbox and per-turn checkpoints keep a long autonomous run something you can still read and undo.</p>
<p align="center"><strong>Open source · MIT · runs on your computer, model of your choice</strong></p>
<h3 align="center">An open-source coding agent for your terminal, desktop, browser and editor.</h3>
<p align="center">It reads your project, edits files and runs commands and tests. Have it ask before each step, or work on its own within limits you set. Files its edit tools changed can be rewound. New here? Start with the Studio desktop app.</p>
<p align="center">Maintained by <strong>Huahui Yu</strong>.</p>
> [!IMPORTANT]
@@ -57,6 +57,42 @@
<br/>
## ❤️Sponsor
<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
Reasonix is an open-source AI coding assistant. The program runs on your computer; the AI model is a service you configure, which receives what it reads. You describe a task in plain language; it reads code, edits files and runs commands and tests inside the project folder you choose, and shows you each step.
You pick the model: a DeepSeek preset is built in, any OpenAI-compatible service is one config entry, and you pay the model provider directly.
## What you get
- **Read, edit, run.** It reads code, edits files and runs tests and commands, all inside your project folder.
- **Permission levels.** Have it ask every time, or let everyday actions run on their own; your forbid rules always hold. To see the approach first, use plan mode: it starts work only once you approve.
- **Rewind.** A file is saved before it changes, so you can rewind by conversation turn without touching your git history. Only files its edit tools changed are covered, not what a shell command did.
## 30-second start
Download, add a model key, open a project. New here? Start with the Studio desktop app.
1. Download and install the desktop app, Reasonix Studio, from the [releases page](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true).
2. Open it and enter your model provider and key when asked.
3. Open your project folder and write what you want, for example "add retry on failure to the http client".
4. At the level you chose, it stops and asks before anything that needs your say-so. Allow it to continue, or refuse.
Prefer a terminal? Download the `reasonix` command-line tool, run `reasonix setup` to pick a model, then run `reasonix`. See [Install](#install) and [Quick start](#quick-start).
## Four ways to use it
- **Desktop app (Studio).** A graphical app; the best place to start.
- **Terminal.** A full-screen text interface; `reasonix run "task"` also works in scripts.
- **Browser.** `reasonix web` opens a web interface on your own machine.
- **Editor.** Install the extension in VS Code; other editors that support ACP (Agent Client Protocol, a common way for an editor to connect to an AI assistant) can connect to `reasonix acp`.
All four are the same program underneath. What follows is for advanced users and contributors.
## Versions
Reasonix ships on two lines. See the [version roadmap announcement](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748) for the reasoning behind the split.
@@ -82,6 +118,8 @@ or reimplemented on 2.x where they fit the new architecture.
## Features
Technical highlights for advanced users.
- **Config-driven.** Providers, the agent, enabled tools, and plugins are all
declared in `reasonix.toml`. No hardcoded models.
- **Multi-model & composable.** DeepSeek ships as a preset; any
@@ -238,11 +276,11 @@ A small list of folks whose work has shaped Reasonix the most — the current to
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
<!-- reasonix-top-contributors:end -->
Special thanks to [**Bernardxu123**](https://github.com/Bernardxu123) for designing the project logo and intro video.
+46 -8
View File
@@ -43,9 +43,9 @@
<br/>
<p align="center"><strong>开源 · MIT · 单个 Go 二进制</strong></p>
<h3 align="center">面向复杂软件工程任务的可靠编码 Agent。</h3>
<p align="center">一套本地引擎,四个入口——终端、桌面端、浏览器,或通过 ACP 接入你的编辑器。计划模式、权限、工作区沙箱与逐轮 checkpoint,让长时间自治运行始终可读、可撤销。</p>
<p align="center"><strong>开源 · MIT · 程序跑在你电脑上,模型自选</strong></p>
<h3 align="center">终端、桌面、浏览器、编辑器里都能用的 AI 编程助手。</h3>
<p align="center">它会读你的项目、改文件、跑命令和测试。每一步先问你,或在你划定的范围内自己做,由你选;它用编辑工具改过的文件可以退回去。第一次用,从桌面应用 Studio 开始。</p>
<p align="center">维护者:<strong>Huahui Yu</strong>。</p>
> [!IMPORTANT]
@@ -55,6 +55,42 @@
> 抖音:**做游戏的小鱼**(抖音号 `22703872788`)· [二维码](./docs/assets/douyin.png)
## ❤️赞助商
<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>
## 它是什么
Reasonix 是一个开源的 AI 编程助手。程序跑在你的电脑上;AI 模型是你自己配置的服务,它读到的内容会发给这个服务。你用大白话描述任务,它在你选的项目文件夹里读代码、改文件、跑命令和测试,每一步都摆给你看。
模型你自己选:内置 DeepSeek 预设,其他兼容 OpenAI 接口的服务填一条配置就行,费用直接付给模型服务商。
## 你能得到什么
- **读、改、跑**:读你的项目、改文件、跑测试和命令,都在你选的项目文件夹里。
- **权限分档**:每次都问你,或让日常操作自动放行;你设的禁止规则始终有效。想先看方案,就开计划模式,批准后它才动手。
- **可回退**:改文件前先存快照,按对话轮次回退,不碰 git 历史。只覆盖编辑工具改的文件,命令行命令造成的改动不在内。
## 30 秒上手
下载、填模型密钥、打开项目。第一次用,从桌面应用 Studio 开始。
1. 从[发布页](https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true)下载并安装桌面应用 Reasonix Studio。
2. 打开应用,按提示填入模型服务商和密钥。
3. 打开你的项目文件夹,写下想做的事,例如“给 http 客户端加上失败重试”。
4. 按你选的档位,该问你的操作它会先停下来问;同意就继续,不同意就拒绝。
更喜欢终端的话,下载 `reasonix` 命令行后运行 `reasonix setup` 配好模型,再运行 `reasonix` 即可。详见[安装](#安装)和[快速开始](#快速开始)。
## 四种用法
- **桌面应用(Studio)**:图形界面,适合第一次使用。
- **终端**:全屏文字界面,也能用 `reasonix run "任务"` 在脚本里运行。
- **浏览器**:`reasonix web` 在本机打开网页界面。
- **编辑器**:VS Code 安装扩展即可;其他支持 ACP(Agent Client Protocol,编辑器接入某个 AI 助手的通用约定)的编辑器,可连接 `reasonix acp`。
四种用法背后是同一套程序。下面的内容写给进阶用户和贡献者。
## 版本
Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://github.com/esengine/DeepSeek-Reasonix/discussions/10748)。
@@ -76,6 +112,8 @@ Reasonix 分为两条版本线,调整的原因见[版本路线公告](https://
## 特性
以下是写给进阶用户的技术要点。
- **配置驱动**:provider、agent、启用的工具、插件全部在 `reasonix.toml` 中声明,
内核无硬编码模型。
- **多模型 · 可组合**:DeepSeek 作为预设内置;任何 OpenAI 兼容
@@ -220,11 +258,11 @@ CLI 进阶用法和详细配置见 **[CLI 命令参考](./docs/CLI.zh-CN.md)**
<!-- reasonix-top-contributors:start -->
| Contributor | Contributor | Contributor | Contributor |
| --- | --- | --- | --- |
| [**SivanCola**](https://github.com/SivanCola) | [**esengine**](https://github.com/esengine) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| **reasonix** | [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | **merge-order-check** | [**light-front-theory**](https://github.com/light-front-theory) | **Yan Li** |
| [**eghrhegpe**](https://github.com/eghrhegpe) | **wufengfan** | [**Bernardxu123**](https://github.com/Bernardxu123) | [**HaoyueQin**](https://github.com/HaoyueQin) |
| [**CVEngineer66**](https://github.com/CVEngineer66) | [**KHG420**](https://github.com/KHG420) | [**JesonChou**](https://github.com/JesonChou) | [**SuMuxi66**](https://github.com/SuMuxi66) |
| [**SivanCola**](https://github.com/SivanCola) | [**KHG420**](https://github.com/KHG420) | [**ttmouse**](https://github.com/ttmouse) | [**lifu963**](https://github.com/lifu963) |
| [**HUQIANTAO**](https://github.com/HUQIANTAO) | [**Harbor404**](https://github.com/Harbor404) | [**GTC2080**](https://github.com/GTC2080) | [**mchenziyi**](https://github.com/mchenziyi) |
| [**Li-Charles-One**](https://github.com/Li-Charles-One) | [**light-front-theory**](https://github.com/light-front-theory) | [**eghrhegpe**](https://github.com/eghrhegpe) | [**Bernardxu123**](https://github.com/Bernardxu123) |
| [**HaoyueQin**](https://github.com/HaoyueQin) | [**chuiwenwei**](https://github.com/chuiwenwei) | [**BuGlessRB**](https://github.com/BuGlessRB) | [**CVEngineer66**](https://github.com/CVEngineer66) |
| [**JesonChou**](https://github.com/JesonChou) | [**lanshi17**](https://github.com/lanshi17) | [**par73e**](https://github.com/par73e) | [**SuMuxi66**](https://github.com/SuMuxi66) |
<!-- reasonix-top-contributors:end -->
特别感谢 [**Bernardxu123**](https://github.com/Bernardxu123) 设计的项目 logo和开场视频。
+3 -2
View File
@@ -369,6 +369,7 @@ out of how a path is spelled, which cannot tell `internal/auth` from
- sensitive: internal/ext/pluginpkg/**
- sensitive: internal/base/netclient/**
- sensitive: internal/safety/redirectguard/**
- sensitive: internal/safety/endpointclient/**
- sensitive: internal/safety/egress/**
- sensitive: internal/platform/browser/**
- sensitive: internal/platform/computer/**
@@ -402,8 +403,8 @@ observation, never by a check that ran afterwards.
What follows bounds Observable today. It is not a restatement of the invariant:
moving a number here must not move the promise.
- `scanWorkspace` walks the workspace recursively, skips VCS store dirs, and is
`complete` only below 50k files. Over that, or past a walk error, it
- `scanWorkspace` walks the workspace and every granted extra root recursively,
skips VCS store dirs, and is `complete` only below 50k files in total. Over that, or past a walk error, it
establishes nothing and the mutation stays unproven.
- Symlinks are recorded by `Lstat`, so a write through one lands outside what
the walk compares. Confining the resolved target is the sandbox's job.
+1 -1
View File
@@ -420,7 +420,7 @@ own outcome).
| `-suite` | `benchmarks/e2e` | Suite root (must contain `tasks/<id>/`). |
| `-task` | *(all)* | Suite mode: run only these comma-separated task IDs (e.g. `-task fix-add-bug`); unknown IDs fail with the available list. |
| `-attempts` | `1` | Suite and diff modes: retry a task until an attempt passes, up to N; enables the `Pass@≤N` KPI, and TTCS charges a retried solve with its failed attempts' wall. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (tasks that passed every trial), `pass@N` (any trial), mean `pass@1` over all trials, and the flaky tasks by name. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-trials` | `1` | Suite mode: run every task N times and keep every outcome. Adds a **Reliability** line: `pass^N` (complete tasks that passed every trial) and `pass@N` (complete tasks with any pass), both over the tasks that ran all N trials only (`n/a` when none did), mean `pass@1` over every graded trial, and the flaky tasks by name; a task the token budget cut short is listed on an **Incomplete** line and is in neither. `Pass@≤N` asks whether the agent can solve a task; `pass^N` asks whether it does so every time. Cannot be combined with `-attempts`. |
| `-tape-record DIR` | — | Suite mode, with `-meter`: record every provider exchange at the meter, one tape per task and trial (`DIR/<task>/trial-N/0001.request.json`, `.response.json`, `.response.body`). Taped runs use a fixed workdir path and pin the seeded files' times, because both reach the model through the workspace block and file listings. |
| `-tape-replay DIR` | — | Suite mode, with `-meter`: answer each run from its tape instead of the provider, and add a **Replay** line naming the first request whose body differs from the recorded one, down to the message (`request 2, message 4 (tool)`), the tool list, or a field. The diverging request is kept as `NNNN.replayed.json` beside the recorded one. A kernel change that should not alter what the model is asked replays with every request matched. |
| `-bin` | `reasonix` | Path to the reasonix binary. |
+21 -10
View File
@@ -22,17 +22,28 @@ addresses by hand at roughly nine times the page-in cost (139 vs 1292
tokens/task). Measured after three separate leaks were closed, so the numbers
are from a clean environment.
**Fold index marginal utility: unresolved.** Both the positive and the negative
estimates are invalidated. The first Stage 2 ran in a contaminated environment.
The clean paired batch found a consistent effect (six of six tasks searched less
and paged in less with the cue visible), and the same-batch dose-response did
not reproduce it (`boundary-aligned 0/4`, recall-token delta reversing sign).
The most likely reason is that two of the six index tasks carry heavy-tail
stopping behaviour that swamps the effect being measured.
**Fold index marginal utility: direction reproduced, size still conditional.** On the v2
substrate the direction holds in six of six tasks and in the pooled paired batch: with the
cue visible the index addresses the target, so the same task is searched less and pages in
less (0.67 searches and 70 recall tokens per task, against 1.50 and 120 with the cue
absent).
Existing evidence is not sufficient to tune the shipped 1% budget in either
direction. Reopening this wants a new corpus, not more samples: that substrate
now exists, two qualifying tasks per cue tier — see "Index corpus v2" below.
A size is visible but is not yet quotable. On the four tasks whose absent-cue arm stayed
out of a loop, the index cost 64,952 prompt tokens against 115,551 without, 44% less
(`i106-budgetseal` -35,147, `i70-ingestfloor` -8,222, `i37-dispatchlag` -7,599, and
`i112-lagquota` +369, the prefix rent a task that never searches pays). That is a
**post-hoc subset**: `i118-fencelag` and `i47-tenantlease` are excluded because their
absent-cue arm looped in this batch (five and three retrieval rounds, three escapes each;
together 80% of the absent-cue total), and the qualification above does not screen for
stopping behaviour — holding on both boundary arms across three passes shows a stable cue
boundary, not a stopping arm. Quoting 44% as the index's effect would mean choosing the
sample after seeing the result; a batch screened on stopping stability is what would make
a size quotable, and one arm also reverses either way (`i70-ingestfloor` default, +11,135
prompt tokens over three escapes).
Batch facts, because the counts differ: 36 runs were executed (12 paired, 24 index), and
the token totals here cover the 24-run index batch — the paired batch ran before the
per-run usage line existed.
**Things that turned out not to be problems**, each after being measured rather
than argued about:
@@ -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)
}
}
+35 -6
View File
@@ -17,6 +17,7 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
@@ -35,6 +36,7 @@ import (
"reasonix/internal/frontend/traystate"
"reasonix/internal/platform/account"
"reasonix/internal/platform/appupdate"
"reasonix/internal/platform/crashreport"
"reasonix/internal/platform/feedback"
"reasonix/internal/platform/instanceid"
"reasonix/internal/platform/notify"
@@ -66,6 +68,7 @@ type shellIdentity struct {
version string
exe string
pid int
logsDir string
}
func (s shellIdentity) stated() bool {
@@ -88,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
}
@@ -96,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,
@@ -127,6 +130,7 @@ func main() {
studioApp := flag.String("studio-app", "", "the application executable this host runs inside")
studioAppPID := flag.Int("studio-app-pid", 0, "the process id of that application")
computerHelper := flag.String("computer-helper", "", "the native helper that operates this machine's applications")
crashDir := flag.String("crash-dir", "", "the directory a fatal runtime error is written to")
stripGrants := flag.Bool("strip-package-grants", false, "remove app-package grants from the directory -studio-app runs from, print what changed, and exit")
flag.Parse()
if *stripGrants {
@@ -140,8 +144,27 @@ func main() {
return
}
boot.SetComputerHelper(*computerHelper)
shell := shellIdentity{version: *studioVersion, exe: *studioApp, pid: *studioAppPID}
os.Exit(run(parentLease(os.Stdin), os.Stdout, os.Stderr, *page, shell))
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
}
return version
}
// run serves until the lease ends or the process is signalled. stdout carries
@@ -358,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,
@@ -366,13 +393,15 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
DecorateSink: decorate,
Tray: &studioTray{tracker: tracker},
Notifications: notifySet,
NotifySender: notify.NewPlatformSender(),
BrowserHost: browserHost,
Asks: asks,
Remote: remotehost.New(ctx, version, asks),
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
Install: studioInstall(shell),
Update: studioUpdateHost(shell, handshakeTo),
Update: studioUpdateHost(shell, owner),
Share: share,
LogsDir: shell.logsDir,
})
share.Attach(hub.Handler())
srv := serve.New(built.Controller, bc, hubCfg)
@@ -416,7 +445,7 @@ func startCloudRemote(ctx context.Context, cfg *config.Config, logs io.Writer, t
registrar.SetCloudRemoteStatus(func() serve.CloudRemoteStatus {
status := host.Status()
return serve.CloudRemoteStatus{
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error,
DeviceID: status.DeviceID, Name: status.Name, Online: status.Online, Error: status.Error, Reason: string(status.Reason),
}
})
}
+10 -2
View File
@@ -9,13 +9,21 @@ import (
"sync"
)
// The acts a handover asks of the shell. Only two travel: releasing the
// application is this process's own hub, and the shell has nothing to do for it.
// The acts this process asks of the shell. Releasing the application is this
// process's own hub, so the shell has nothing to do for it; the other three are
// what only the shell can do.
const (
actRelaunch = "relaunch"
actQuit = "quit"
actShow = "show"
)
// showOnToastClick is what a clicked toast runs: ask the shell, which is the
// only process that can bring its window forward, to do so.
func showOnToastClick(o *shellOwner) func() {
return func() { _ = o.ask(actShow) }
}
// shellOwner is the application this process was spawned by, reached over the
// pipe the handshake went out on -- the only channel pointing this way, since
// stdin is the lease and HTTP runs from the shell to here. Nothing is waited
+12 -2
View File
@@ -78,7 +78,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
{"an unpackaged launch, which names no version", shellIdentity{exe: "/somewhere/studio", pid: 4321}},
} {
t.Run(tc.name, func(t *testing.T) {
if got := studioUpdateHost(tc.shell, &out); got != nil {
if got := studioUpdateHost(tc.shell, &shellOwner{to: &out}); got != nil {
t.Fatalf("studioUpdateHost = %#v, want nil so no install route registers", got)
}
})
@@ -89,7 +89,7 @@ func TestAHostThatCannotNameTheApplicationOwnsNoCapability(t *testing.T) {
func TestAHostThatNamesTheApplicationOwnsOne(t *testing.T) {
var out bytes.Buffer
shell := shellIdentity{version: "v2.0.0", exe: "/Applications/Reasonix Studio.app/Contents/MacOS/Reasonix Studio", pid: 4321}
if studioUpdateHost(shell, &out) == nil {
if studioUpdateHost(shell, &shellOwner{to: &out}) == nil {
t.Fatal("a host that stated its application got no capability")
}
}
@@ -116,3 +116,13 @@ func TestTheInstallLayoutFollowsTheStatedApplication(t *testing.T) {
t.Fatalf("studioInstall = %+v for a launch that named no version, want nil", got)
}
}
// A clicked toast reaches the shell as one line on the pipe the handshake used,
// in the same shape the other acts take.
func TestAToastClickAsksTheShellToShowItsWindow(t *testing.T) {
var out bytes.Buffer
showOnToastClick(&shellOwner{to: &out})()
if got := out.String(); got != "{\"act\":\"show\"}\n" {
t.Fatalf("pipe carried %q, want the show act", got)
}
}
+3 -2
View File
@@ -191,12 +191,13 @@ func TestPublishRefusesAPartialWindowsSet(t *testing.T) {
root := parseWorkflowFile(t, "../../.github/workflows/release-studio.yml")
publish := mappingValue(jobsOf(t, root), "publish")
run := mappingScalar(stepNamed(t, publish, "Collect artifacts"), "run")
if !strings.Contains(run, `"$count" -lt 9`) {
t.Error("publish does not require at least 9 ReasonixStudio-* packages")
if !strings.Contains(run, `"$count" -lt 10`) {
t.Error("publish does not require at least 10 ReasonixStudio-* packages")
}
for _, name := range []string{
"ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-arm64-installer.exe",
"ReasonixStudio-windows-amd64.zip", "ReasonixStudio-windows-arm64.zip",
"ReasonixStudio-linux-*.deb", "ReasonixStudio-linux-*.rpm",
} {
if !strings.Contains(run, "'"+name+"'") {
t.Errorf("publish does not require %s by name", name)
+1 -1
View File
@@ -36,7 +36,7 @@ are written down in [`docs/STUDIO_SHELL_BOUNDARIES.md`](../docs/STUDIO_SHELL_BOU
| `electron/` | the shell: main process, preload bridge, packaging |
| `frontend-next/` | the SPA it serves |
| `internal/platform/update/` | manifest, download, verify, apply — shared by the shell and the helper |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux) |
| `cmd/studio-manifest/`, `cmd/sign/` | release-side tools |
| `internal/winuninstall/` | taking a previous Windows install over |
+1 -30
View File
@@ -63,11 +63,6 @@ func main() {
notesVersion = os.Args[5]
}
err = genManifest(os.Args[2], os.Args[3], os.Args[4], notesVersion)
case "windows-payload":
if len(os.Args) != 4 {
usage()
}
err = genWindowsPayloadManifest(os.Args[2], os.Args[3])
case "genkey":
if len(os.Args) != 3 {
usage()
@@ -88,34 +83,10 @@ func main() {
}
func usage() {
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n windows-payload <dir> <version>\n genkey <dir>\n verify <file>")
fmt.Fprintln(os.Stderr, "usage:\n sign <file>...\n manifest <dir> <version> <tag> [notes-version]\n genkey <dir>\n verify <file>")
os.Exit(2)
}
func genWindowsPayloadManifest(dir, version string) error {
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
path := filepath.Join(dir, name)
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("Windows payload %s: %w", name, err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("Windows payload %s is not a regular file", name)
}
_, sum, err := hashFile(path)
if err != nil {
return err
}
hashes[name] = sum
}
b, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, update.WindowsPayloadManifestName), b, 0o644)
}
// verifyFile checks <file> against <file>.minisig using the embedded public key —
// the same check the updater runs before applying. A self-test that the signing
// key matches what's compiled in. Returns an error (nonzero exit) on mismatch.
-26
View File
@@ -206,29 +206,3 @@ func TestGenManifestIgnoresUnknownNativePackages(t *testing.T) {
t.Fatalf("unexpected native_packages: %+v", m.NativePackages)
}
}
func TestGenWindowsPayloadManifestHashesExactReleaseUnit(t *testing.T) {
dir := t.TempDir()
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(dir, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := genWindowsPayloadManifest(dir, "v2.3.4"); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(dir, update.WindowsPayloadManifestName))
if err != nil {
t.Fatal(err)
}
hashes, err := update.DecodeWindowsPayloadManifest(b, "v2.3.4")
if err != nil {
t.Fatal(err)
}
for _, name := range update.WindowsPayloadFileNames() {
want := update.WindowsPayloadSHA256([]byte("payload:" + name))
if hashes[name] != want {
t.Fatalf("manifest hash for %s = %q, want %q", name, hashes[name], want)
}
}
}
+33 -1
View File
@@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"reasonix/internal/platform/update"
@@ -13,7 +14,8 @@ import (
// archMap is desktop/electron/arch.json: what the packager builds for, and what
// each of those is called in a release.
type archMap struct {
Map map[string]string `json:"map"`
Map map[string]string `json:"map"`
FPM map[string][]string `json:"fpm"`
}
func loadArchMap(t *testing.T) archMap {
@@ -93,3 +95,33 @@ func TestThePackagerSpellingResolvesToAKeyNobodyAsksFor(t *testing.T) {
}
}
}
// fpm names an .rpm for the architecture its own way, and the parser accepts
// that name too: it keys a package nobody asks for. The packager hook renames
// these spellings, so every one must be a non-canonical key here.
func TestFPMSpellingOfAnRPMIsNotAReleaseKey(t *testing.T) {
m := loadArchMap(t)
if len(m.FPM) == 0 {
t.Fatal("arch.json lists no fpm spellings")
}
canonical := map[string]bool{}
for _, to := range m.Map {
canonical[to] = true
}
for from, spellings := range m.FPM {
to, ok := m.Map[from]
if !ok {
t.Errorf("fpm spells %s, which map does not release", from)
continue
}
if got, ok := rpmPackageKey(artifactPrefix + "linux-" + to + ".rpm"); !ok || got != update.RPMPackageKey("linux", to) {
t.Errorf("linux %s rpm = %q (%v)", to, got, ok)
}
for _, spelled := range spellings {
got, ok := rpmPackageKey(artifactPrefix + "linux-" + spelled + ".rpm")
if !ok || canonical[strings.TrimSuffix(strings.TrimPrefix(got, "linux-"), "-rpm")] {
t.Errorf("%s resolved to %q (%v); the rename is no longer load-bearing", spelled, got, ok)
}
}
}
}
+22 -1
View File
@@ -84,6 +84,12 @@ func run(dir, version, tag, deltaDir string, mirrored bool) error {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// The .rpm rides beside the .deb under its own key: the deb's key is the
// one every installed client already reads.
if key, ok := rpmPackageKey(name); ok {
m.NativePackages[key] = asset
fmt.Printf("native package: %s -> %s\n", name, key)
}
// Windows updates by running the next installer, so that is what the
// platform lookup has to resolve. The portable archive is never listed:
// resolving it would hand the updater an artifact it cannot install.
@@ -135,7 +141,8 @@ func releaseAsset(repo, tag, name string, mirrored bool) update.Asset {
func installable(name string) bool {
return strings.HasSuffix(name, ".dmg") ||
strings.HasSuffix(name, "-installer.exe") ||
strings.HasSuffix(name, ".deb")
strings.HasSuffix(name, ".deb") ||
strings.HasSuffix(name, ".rpm")
}
// macArchiveKeys maps a macOS archive to the platforms it can install. The
@@ -185,6 +192,20 @@ func nativePackageKey(name string) (string, bool) {
return update.PlatformKey("linux", arch), true
}
// rpmPackageKey maps a ReasonixStudio-linux-<arch>.rpm artifact to its
// native_packages key.
func rpmPackageKey(name string) (string, bool) {
base, ok := strings.CutSuffix(name, ".rpm")
if !ok {
return "", false
}
arch, ok := strings.CutPrefix(base, artifactPrefix+"linux-")
if !ok || arch == "" {
return "", false
}
return update.RPMPackageKey("linux", arch), true
}
func hashFile(path string) (int64, string, error) {
f, err := os.Open(path)
if err != nil {
+54
View File
@@ -232,3 +232,57 @@ func TestWindowsArchitecturesResolveToTheirOwnInstallers(t *testing.T) {
t.Errorf("no delta directory was given, yet deltas = %v", m.Deltas)
}
}
// Every installed Linux client reads native_packages["linux-amd64"] as the
// .deb and hands it to dpkg. The .rpm may add a key and never change that one.
func TestRPMRidesBesideTheDebWithoutMovingItsKey(t *testing.T) {
write := func(dir string, names ...string) {
for _, name := range names {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
t.Fatal(err)
}
}
}
generate := func(dir string) update.Manifest {
t.Setenv("GITHUB_REPOSITORY", "esengine/DeepSeek-Reasonix")
if err := run(dir, "v0.1.0", "studio-v0.1.0", "", true); err != nil {
t.Fatalf("run: %v", err)
}
var m update.Manifest
raw, err := os.ReadFile(filepath.Join(dir, "latest.json"))
if err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
debOnly, both := t.TempDir(), t.TempDir()
write(debOnly, "ReasonixStudio-linux-amd64.deb")
write(both, "ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm")
before, after := generate(debOnly), generate(both)
if after.NativePackages["linux-amd64"] != before.NativePackages["linux-amd64"] {
t.Errorf("the deb entry moved: %+v, was %+v", after.NativePackages["linux-amd64"], before.NativePackages["linux-amd64"])
}
if !strings.HasSuffix(after.NativePackages["linux-amd64"].URL, "linux-amd64.deb") {
t.Errorf("native_packages[linux-amd64] = %+v, want the .deb", after.NativePackages["linux-amd64"])
}
rpm, ok := after.NativePackages["linux-amd64-rpm"]
if !ok || !strings.HasSuffix(rpm.URL, "ReasonixStudio-linux-amd64.rpm") || rpm.Sig != rpm.URL+".minisig" {
t.Errorf("native_packages[linux-amd64-rpm] = %+v, want the signed .rpm", rpm)
}
if len(after.NativePackages) != 2 {
t.Errorf("native packages = %v, want the deb and the rpm", after.NativePackages)
}
if _, ok := after.Platforms["linux-amd64"]; ok {
t.Error("the .rpm must not resolve as a platform asset")
}
if _, ok := after.Downloads["ReasonixStudio-linux-amd64.rpm"]; !ok {
t.Error("downloads must offer the .rpm to a person")
}
if len(after.Downloads) != len(before.Downloads)+1 {
t.Errorf("downloads = %v, want only the rpm added", after.Downloads)
}
}
-15
View File
@@ -1,15 +0,0 @@
package main
import "fmt"
func installerCommandLine(installer, dir string) string {
// Auto-updates use a dedicated visible progress mode instead of NSIS /S.
// The current helper also requires staging-only extraction: NSIS never writes
// the live install, and the helper compare-and-publishes the claimed release
// unit after validating every staged member.
line := fmt.Sprintf(`"%s" /REASONIXUPDATE=1 /REASONIXSTAGE=1`, installer)
if dir != "" {
line += " /D=" + dir
}
return line
}
-23
View File
@@ -1,23 +0,0 @@
package main
import (
"strings"
"testing"
)
func TestInstallerCommandLineUsesVisibleUpdateModeAndLeavesDFlagLast(t *testing.T) {
got := installerCommandLine(`C:\Temp\Reasonix Installer.exe`, `D:\Tools\Reasonix App`)
want := `"C:\Temp\Reasonix Installer.exe" /REASONIXUPDATE=1 /REASONIXSTAGE=1 /D=D:\Tools\Reasonix App`
if got != want {
t.Fatalf("installerCommandLine = %q, want %q", got, want)
}
if strings.Contains(got, " /S") {
t.Fatalf("auto-update must expose progress instead of using silent mode, got %q", got)
}
if !strings.HasSuffix(got, `/D=D:\Tools\Reasonix App`) {
t.Fatalf("/D= must be the final unquoted NSIS token, got %q", got)
}
if !strings.Contains(got, " /REASONIXSTAGE=1") {
t.Fatalf("auto-update must extract away from the live install, got %q", got)
}
}
@@ -1,10 +0,0 @@
//go:build !windows
package main
import "os"
// Windows verifies each executable with the OS Authenticode policy while the
// same read handle is held. Non-Windows builds keep the release-unit loader
// testable without importing platform-specific trust APIs.
var readVerifiedWindowsStagedPayloadFn = os.ReadFile
@@ -1,97 +0,0 @@
//go:build windows
package main
import (
"fmt"
"io"
"os"
"unsafe"
"golang.org/x/sys/windows"
)
var readVerifiedWindowsStagedPayloadFn = readVerifiedWindowsStagedPayload
// readVerifiedWindowsStagedPayload verifies the Authenticode signature and
// reads the payload through one handle that disallows concurrent writes,
// deletes, and renames. The bytes handed to the publisher therefore cannot
// change between signature verification and publication.
func readVerifiedWindowsStagedPayload(path string) ([]byte, error) {
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
path16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
path16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
if file == nil {
_ = windows.CloseHandle(handle)
return nil, fmt.Errorf("open staged payload handle")
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
return nil, err
}
if !fileInfo.Mode().IsRegular() {
return nil, fmt.Errorf("staged payload is not a regular file")
}
trustFile := &windows.WinTrustFileInfo{
Size: uint32(unsafe.Sizeof(windows.WinTrustFileInfo{})),
FilePath: path16,
File: handle,
}
trustData := &windows.WinTrustData{
Size: uint32(unsafe.Sizeof(windows.WinTrustData{})),
UIChoice: windows.WTD_UI_NONE,
RevocationChecks: windows.WTD_REVOKE_NONE,
UnionChoice: windows.WTD_CHOICE_FILE,
FileOrCatalogOrBlobOrSgnrOrCert: unsafe.Pointer(trustFile),
StateAction: windows.WTD_STATEACTION_VERIFY,
UIContext: windows.WTD_UICONTEXT_EXECUTE,
}
verifyErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
trustData.StateAction = windows.WTD_STATEACTION_CLOSE
closeErr := windows.WinVerifyTrustEx(
windows.InvalidHWND,
&windows.WINTRUST_ACTION_GENERIC_VERIFY_V2,
trustData,
)
if verifyErr != nil {
if closeErr != nil {
return nil, fmt.Errorf("Authenticode verification failed: %w (release trust state: %w)", verifyErr, closeErr)
}
return nil, fmt.Errorf("Authenticode verification failed: %w", verifyErr)
}
if closeErr != nil {
return nil, fmt.Errorf("release Authenticode verification state: %w", closeErr)
}
if _, err := file.Seek(0, io.SeekStart); err != nil {
return nil, err
}
return io.ReadAll(file)
}
+8 -5
View File
@@ -37,11 +37,11 @@ const (
exitPostVerify = 16
)
// packageName is the only dpkg package this helper will install, fixed at
// build time so no runtime input can redirect it. Studio builds the same
// source with its own name; each package installs its own binary, and Polkit
// authorizes that path alone.
var packageName = "reasonix-desktop"
// packageName is the only dpkg package this helper will install. It is Studio's
// line as the update path states it, compiled in, so no runtime input can
// redirect it and no build step has a value to forget; Polkit authorizes this
// helper's own path alone.
var packageName = update.StudioLine().Deb.Package
const (
dpkgDebPath = "/usr/bin/dpkg-deb"
@@ -124,6 +124,9 @@ func runWith(d installDeps, args []string) int {
switch args[0] {
case "install":
return runInstall(d, args[1:])
case "package":
fmt.Fprintln(os.Stdout, packageName)
return exitOK
default:
d.writeResult(helperResult{OK: false, Error: "unknown command", Code: "usage"})
return exitUsage
@@ -3,6 +3,7 @@
package main
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
@@ -14,6 +15,7 @@ import (
"aead.dev/minisign"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/update"
)
func TestSanitizeHelperErrorStripsPaths(t *testing.T) {
@@ -596,3 +598,44 @@ func TestMinisignRejectsWrongKeyAndTamper(t *testing.T) {
t.Fatalf("exit = %d, want verify failed; results=%+v", code, probe.results)
}
}
// The helper installs one package and the shell asks dpkg about the same one:
// both must come from the update path's own line, or an in-app update is
// refused as a different package.
func TestHelperInstallsTheLineTheUpdatePathNames(t *testing.T) {
if packageName != update.StudioLine().Deb.Package {
t.Fatalf("helper accepts %q, the update path names %q", packageName, update.StudioLine().Deb.Package)
}
var out bytes.Buffer
old := os.Stdout
r, w, _ := os.Pipe()
os.Stdout = w
code := runWith(realDeps(), []string{"package"})
w.Close()
os.Stdout = old
_, _ = out.ReadFrom(r)
if code != exitOK || strings.TrimSpace(out.String()) != packageName {
t.Fatalf("package command = %d %q, want %q", code, out.String(), packageName)
}
}
// electron-builder names the packages; the helper must agree with both.
func TestPackagedNamesMatchTheHelper(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("..", "..", "electron", "electron-builder.yml"))
if err != nil {
t.Fatal(err)
}
names := map[string]string{}
section := ""
for line := range strings.SplitSeq(string(raw), "\n") {
if line != "" && !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "#") && strings.HasSuffix(strings.TrimSpace(line), ":") {
section = strings.TrimSuffix(strings.TrimSpace(line), ":")
}
if rest, ok := strings.CutPrefix(line, " packageName:"); ok {
names[section] = strings.TrimSpace(rest)
}
}
if names["deb"] != packageName || names["rpm"] != packageName {
t.Fatalf("electron-builder.yml names deb %q and rpm %q, the helper accepts %q", names["deb"], names["rpm"], packageName)
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//go:build !windows && !linux
//go:build !linux
package main
@@ -8,6 +8,6 @@ import (
)
func main() {
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Windows and Linux desktop builds")
fmt.Fprintln(os.Stderr, "reasonix-update-helper is only used by Linux builds")
os.Exit(2)
}
-581
View File
@@ -1,581 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"flag"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"golang.org/x/sys/windows"
"reasonix/desktop/internal/winuninstall"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
const parentExitTimeout = 2 * time.Minute
var (
waitForProcessExitFn = waitForProcessExit
runInstallerFn = runInstaller
startRelaunchFn = startRelaunch
claimPendingFileUpdateFn = repair.ClaimPendingFileUpdateExact
installStagedReleaseUnitFn = installStagedWindowsReleaseUnit
recordInstalledUpdateFn = repair.RecordClaimedFileUpdateInstalled
stageInstallerFn = stageVerifiedInstaller
claimInstallerExecutionFn = claimVerifiedInstallerForExecution
lstatUpdateStagingFn = os.Lstat
reconcileWindowsUninstallRegistrationFn = winuninstall.Reconcile
)
func main() {
os.Exit(run(os.Args[1:]))
}
func run(args []string) int {
var parentPID uint
var installer, installerSHA256, installDir, relaunch, toVersion, createdAt, transactionID, installLayout string
fs := flag.NewFlagSet("reasonix-update-helper", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
fs.UintVar(&parentPID, "parent-pid", 0, "Reasonix process id to wait for before installing")
fs.StringVar(&installer, "installer", "", "verified NSIS installer path")
fs.StringVar(&installerSHA256, "installer-sha256", "", "expected SHA-256 of the verified NSIS installer")
fs.StringVar(&installDir, "install-dir", "", "Reasonix installation directory")
fs.StringVar(&relaunch, "relaunch", "", "Reasonix executable to start after the installer succeeds")
fs.StringVar(&toVersion, "to-version", "", "Reasonix version being installed")
fs.StringVar(&createdAt, "created-at", "", "pending update creation timestamp")
fs.StringVar(&transactionID, "transaction-id", "", "complete pending update identity")
fs.StringVar(&installLayout, "install-layout", "", "installation layout contract")
if err := fs.Parse(args); err != nil {
return 2
}
logger := newLogger()
if installer == "" {
logger.Print("missing --installer")
return 2
}
if !validSHA256(installerSHA256) {
logger.Print("missing or invalid --installer-sha256")
return 2
}
if toVersion == "" {
logger.Print("missing --to-version")
return 2
}
if installLayout != "" && installLayout != installlayout.InstallLayoutVersionedV1 {
logger.Printf("unsupported --install-layout %q", installLayout)
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && createdAt == "" {
logger.Print("missing --created-at")
return 2
}
if installLayout != installlayout.InstallLayoutVersionedV1 && transactionID == "" {
logger.Print("missing --transaction-id")
return 2
}
if installDir == "" {
logger.Print("missing --install-dir")
return 2
}
if parentPID != 0 {
if err := waitForProcessExitFn(uint32(parentPID), parentExitTimeout); err != nil {
logger.Printf("wait for parent process %d: %v", parentPID, err)
return 1
}
}
if installLayout == installlayout.InstallLayoutVersionedV1 {
return runVersionedWindowsUpdate(logger, installer, installerSHA256, installDir, relaunch, toVersion)
}
recoverExisting := func(reason string) int {
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after %s: %v", reason, relaunchErr)
}
}
return 1
}
// The detached helper runs from the update cache, so a plain
// repair.ReadPendingUpdate would validate the transaction against the cache
// directory and reject every legitimate flat install. Claim through the
// explicit install-local launcher path instead; the repair package validates
// the complete transaction identity and exact release-unit path set while it
// acquires the pending + target locks.
claimTargets := windowsReleaseUnitPaths(installDir)
claimLauncher := filepath.Join(installDir, "reasonix-desktop.exe")
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
claimed, releaseClaim, err := claimPendingFileUpdateFn(
toVersion,
createdAt,
transactionID,
claimLauncher,
claimTargets,
parentExitTimeout,
)
if err != nil {
logger.Printf("claim pending update: %v", err)
return recoverExisting("pending update claim failure")
}
defer releaseClaim()
recoverUnstarted := func() int {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after unstarted update failure: %v", relaunchErr)
}
}
return 1
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind update installer: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create update staging: %v", err)
return recoverUnstarted()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind update staging: %v", err)
return recoverUnstarted()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged installer: %v", err)
return recoverUnstarted()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract installer payload: %v", installerErr)
return recoverUnstarted()
}
publishStarted, receipts, err := installStagedReleaseUnitFn(claimed, stagingDir)
if err != nil {
logger.Printf("publish staged release unit: %v", err)
if !publishStarted {
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("cancel unstarted update: %v", cancelErr)
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
}
return 0
}
func runVersionedWindowsUpdate(logger *log.Logger, installer, installerSHA256, installDir, relaunch, toVersion string) int {
recoverExisting := func() int {
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch after versioned update failure: %v", err)
}
}
return 1
}
if _, err := installlayout.ReadCurrent(installDir); err != nil {
logger.Printf("read versioned install pointer: %v", err)
return recoverExisting()
}
claimedInstaller, cleanupInstaller, err := stageInstallerFn(installer, installerSHA256)
if err != nil {
logger.Printf("bind versioned update installer: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupInstaller(); cleanupErr != nil {
logger.Printf("preserving update installer staging: %v", cleanupErr)
}
}()
stagingDir, err := os.MkdirTemp("", "reasonix-update-stage-*")
if err != nil {
logger.Printf("create versioned update staging: %v", err)
return recoverExisting()
}
stagingOwner, err := lstatUpdateStagingFn(stagingDir)
if err != nil {
logger.Printf("bind versioned update staging: %v", err)
return recoverExisting()
}
defer func() {
if cleanupErr := cleanupOwnedWindowsUpdateDirectory(stagingDir, stagingOwner); cleanupErr != nil {
logger.Printf("preserving update staging: %v", cleanupErr)
}
}()
releaseInstallerExecution, err := claimInstallerExecutionFn(claimedInstaller, installerSHA256)
if err != nil {
logger.Printf("recheck staged versioned installer: %v", err)
return recoverExisting()
}
installerErr := runInstallerFn(claimedInstaller, stagingDir)
releaseInstallerExecution()
if installerErr != nil {
logger.Printf("extract versioned installer payload: %v", installerErr)
return recoverExisting()
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: toVersion,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
logger.Printf("activate versioned release: %v", err)
return recoverExisting()
}
if _, err := reconcileWindowsUninstallRegistrationFn(installDir, toVersion); err != nil {
// The release is already active and must not be rolled back for stale
// Add/Remove Programs metadata. A later update or full installer retries
// this idempotent reconciliation.
logger.Printf("reconcile Windows uninstall registration: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch versioned release: %v", err)
return 1
}
}
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
return false
}
_, err := hex.DecodeString(value)
return err == nil
}
func stageVerifiedInstaller(sourcePath, expectedSHA256 string) (string, func() error, error) {
if !validSHA256(expectedSHA256) {
return "", nil, fmt.Errorf("installer SHA-256 is invalid")
}
sourceInfo, err := os.Lstat(sourcePath)
if err != nil {
return "", nil, err
}
if !sourceInfo.Mode().IsRegular() {
return "", nil, fmt.Errorf("installer is not a regular file")
}
dir, err := os.MkdirTemp("", "reasonix-update-installer-*")
if err != nil {
return "", nil, err
}
owner, err := os.Lstat(dir)
if err != nil {
return "", nil, err
}
cleanup := func() error {
return cleanupOwnedWindowsUpdateDirectory(dir, owner)
}
fail := func(err error) (string, func() error, error) {
_ = cleanup()
return "", nil, err
}
source, err := os.Open(sourcePath)
if err != nil {
return fail(err)
}
defer source.Close()
stagedPath := filepath.Join(dir, "reasonix-installer.exe")
staged, err := os.OpenFile(stagedPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o700)
if err != nil {
return fail(err)
}
hash := sha256.New()
_, copyErr := io.Copy(io.MultiWriter(staged, hash), source)
syncErr := staged.Sync()
closeErr := staged.Close()
switch {
case copyErr != nil:
return fail(copyErr)
case syncErr != nil:
return fail(syncErr)
case closeErr != nil:
return fail(closeErr)
case !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)):
return fail(fmt.Errorf("installer SHA-256 changed after desktop verification"))
}
if err := verifyInstallerSHA256(stagedPath, expectedSHA256); err != nil {
return fail(err)
}
return stagedPath, cleanup, nil
}
func verifyInstallerSHA256(path, expectedSHA256 string) error {
info, err := os.Lstat(path)
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return fmt.Errorf("installer is not a regular file")
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
hash := sha256.New()
if _, err := io.Copy(hash, f); err != nil {
return err
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fmt.Errorf("installer SHA-256 mismatch")
}
return nil
}
func claimVerifiedInstallerForExecution(path, expectedSHA256 string) (func(), error) {
if !validSHA256(expectedSHA256) {
return nil, fmt.Errorf("installer SHA-256 is invalid")
}
pathUTF16, err := windows.UTF16PtrFromString(path)
if err != nil {
return nil, err
}
handle, err := windows.CreateFile(
pathUTF16,
windows.GENERIC_READ,
windows.FILE_SHARE_READ,
nil,
windows.OPEN_EXISTING,
windows.FILE_ATTRIBUTE_NORMAL|windows.FILE_FLAG_SEQUENTIAL_SCAN,
0,
)
if err != nil {
return nil, err
}
file := os.NewFile(uintptr(handle), path)
fail := func(err error) (func(), error) {
_ = file.Close()
return nil, err
}
info, err := file.Stat()
if err != nil {
return fail(err)
}
if !info.Mode().IsRegular() {
return fail(fmt.Errorf("installer is not a regular file"))
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return fail(err)
}
if !strings.EqualFold(hex.EncodeToString(hash.Sum(nil)), strings.TrimSpace(expectedSHA256)) {
return fail(fmt.Errorf("installer SHA-256 mismatch"))
}
var once sync.Once
return func() {
once.Do(func() { _ = file.Close() })
}, nil
}
func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
// v1.20+: versioned-v1 is the only publish path. Incomplete staged payloads
// fail closed without mutating the live install or leaving flat pending state.
if !preferVersionedWindowsActivation(stagingDir) {
return false, nil, fmt.Errorf("staged payload is incomplete for versioned-v1 activation")
}
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
return true, nil, nil
}
func windowsReleaseUnitPaths(installDir string) []string {
if installDir == "" {
return nil
}
names := []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
"Reasonix.exe",
}
paths := make([]string, 0, len(names))
for _, name := range names {
paths = append(paths, filepath.Join(installDir, name))
}
return paths
}
func newLogger() *log.Logger {
dir, err := os.UserCacheDir()
if err == nil {
dir = filepath.Join(dir, "Reasonix", "updates")
if err := os.MkdirAll(dir, 0o700); err == nil {
if f, err := os.OpenFile(filepath.Join(dir, "update-helper.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600); err == nil {
return log.New(f, "", log.LstdFlags)
}
}
}
return log.New(os.Stderr, "", log.LstdFlags)
}
func waitForProcessExit(pid uint32, timeout time.Duration) error {
h, err := windows.OpenProcess(windows.SYNCHRONIZE, false, pid)
if err != nil {
if errors.Is(err, windows.ERROR_INVALID_PARAMETER) {
return nil
}
return err
}
defer windows.CloseHandle(h)
waitMS := uint32(timeout / time.Millisecond)
result, err := windows.WaitForSingleObject(h, waitMS)
if err != nil {
return err
}
switch result {
case windows.WAIT_OBJECT_0:
return nil
case uint32(windows.WAIT_TIMEOUT):
return fmt.Errorf("timed out after %s", timeout)
default:
return fmt.Errorf("unexpected wait result %d", result)
}
}
func runInstaller(installer, installDir string) error {
cmd := exec.Command(installer)
// Keep the helper itself hidden, but let the NSIS update-progress window be
// visible. /REASONIXSTAGE makes the signed installer extract only; the helper
// performs every live replacement through the claimed transaction.
cmd.SysProcAttr = &syscall.SysProcAttr{CmdLine: installerCommandLine(installer, installDir)}
return cmd.Run()
}
func cleanupOwnedWindowsUpdateDirectory(path string, owner os.FileInfo) error {
if path == "" || owner == nil || !owner.IsDir() {
return fmt.Errorf("Windows update cleanup identity is incomplete")
}
for attempt := range 16 {
cleanup := fmt.Sprintf("%s.reasonix-cleanup-%d-%d", path, time.Now().UTC().UnixNano(), attempt)
from, err := windows.UTF16PtrFromString(path)
if err != nil {
return err
}
to, err := windows.UTF16PtrFromString(cleanup)
if err != nil {
return err
}
if err := windows.MoveFileEx(from, to, windows.MOVEFILE_WRITE_THROUGH); err != nil {
if os.IsNotExist(err) {
return nil
}
if os.IsExist(err) {
continue
}
return err
}
actual, err := os.Lstat(cleanup)
if err != nil {
return err
}
if !os.SameFile(owner, actual) {
restoreFrom, fromErr := windows.UTF16PtrFromString(cleanup)
restoreTo, toErr := windows.UTF16PtrFromString(path)
if fromErr != nil || toErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s", cleanup)
}
if restoreErr := windows.MoveFileEx(restoreFrom, restoreTo, windows.MOVEFILE_WRITE_THROUGH); restoreErr != nil {
return fmt.Errorf("Windows update staging changed before cleanup; preserve replacement at %s: %w", cleanup, restoreErr)
}
return fmt.Errorf("Windows update staging changed before cleanup")
}
return os.RemoveAll(cleanup)
}
return fmt.Errorf("cannot allocate Windows update cleanup path")
}
func startRelaunch(relaunch, installDir string) error {
cmd := exec.Command(relaunch)
cmd.Dir = installDir
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
return cmd.Start()
}
@@ -1,563 +0,0 @@
//go:build windows
package main
import (
"crypto/sha256"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
const testInstallerSHA256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func prepareLegacyWindowsUpdate(t *testing.T, installDir, toVersion string) *repair.UpdateTransaction {
t.Helper()
paths := windowsReleaseUnitPaths(installDir)
for _, path := range paths {
if err := os.WriteFile(path, []byte("old-"+filepath.Base(path)), 0o700); err != nil {
t.Fatal(err)
}
}
tx, err := repair.PrepareFileUpdate("v1", toVersion, paths[0], paths[1:]...)
if err != nil {
t.Fatal(err)
}
return tx
}
func TestStageVerifiedInstallerFreezesExpectedBytes(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(source, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
expected := hex.EncodeToString(sum[:])
staged, cleanup, err := stageVerifiedInstaller(source, expected)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cleanup() })
if staged == source {
t.Fatal("installer was not isolated from the mutable cache path")
}
if err := os.WriteFile(source, []byte("changed-after-handoff"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err != nil {
t.Fatalf("isolated installer changed with source: %v", err)
}
if got, err := os.ReadFile(staged); err != nil || string(got) != string(content) {
t.Fatalf("staged installer = %q, %v", got, err)
}
if err := os.WriteFile(staged, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if err := verifyInstallerSHA256(staged, expected); err == nil {
t.Fatal("tampered staged installer passed final verification")
}
}
func TestStageVerifiedInstallerRejectsSourceHashDrift(t *testing.T) {
source := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(source, []byte("different"), 0o700); err != nil {
t.Fatal(err)
}
if _, cleanup, err := stageVerifiedInstaller(source, testInstallerSHA256); err == nil {
if cleanup != nil {
_ = cleanup()
}
t.Fatal("installer bytes different from desktop verification were accepted")
}
}
func TestRunRequiresTargetVersionBeforeStartingInstaller(t *testing.T) {
if code := run([]string{
"--installer", `C:\Temp\Reasonix-installer.exe`,
"--installer-sha256", testInstallerSHA256,
}); code != 2 {
t.Fatalf("run without --to-version = %d, want 2", code)
}
}
func TestRunVersionedLayoutDoesNotReadOrClaimLegacyPending(t *testing.T) {
installDir := tempdir.New(t)
seed := tempdir.New(t)
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"} {
if err := os.WriteFile(filepath.Join(seed, name), []byte("old-"+name), 0o700); err != nil {
t.Fatal(err)
}
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installDir,
Version: "v1.20.0",
RequestID: "seed-windows-helper",
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: filepath.Join(seed, "reasonix-desktop.exe")},
{Name: "reasonix-cli.exe", Path: filepath.Join(seed, "reasonix-cli.exe")},
{Name: "reasonix-update-helper.exe", Path: filepath.Join(seed, "reasonix-update-helper.exe")},
},
RequiredNames: []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe"},
}); err != nil {
t.Fatal(err)
}
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
originalReconcileUninstall := reconcileWindowsUninstallRegistrationFn
t.Cleanup(func() {
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
reconcileWindowsUninstallRegistrationFn = originalReconcileUninstall
})
legacyClaimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
legacyClaimed = true
return nil, func() {}, errors.New("legacy claim must not run")
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) { return func() {}, nil }
reconciledVersion := ""
reconcileWindowsUninstallRegistrationFn = func(root, targetVersion string) (bool, error) {
if root != installDir {
t.Fatalf("reconcile root = %q, want %q", root, installDir)
}
reconciledVersion = targetVersion
return true, nil
}
runInstallerFn = func(_ string, staging string) error {
for _, name := range []string{"reasonix-desktop.exe", "reasonix-cli.exe", "reasonix-update-helper.exe", "reasonix-launcher.exe"} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("new-"+name), 0o700); err != nil {
return err
}
}
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error { relaunched = true; return nil }
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-launcher.exe"),
"--to-version", "v1.20.1",
"--install-layout", "versioned-v1",
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
if legacyClaimed {
t.Fatal("versioned update claimed legacy pending transaction")
}
if !relaunched {
t.Fatal("versioned update did not relaunch")
}
if reconciledVersion != "v1.20.1" {
t.Fatalf("reconciled version = %q, want v1.20.1", reconciledVersion)
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.1" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
}
func TestRunHoldsReleaseUnitLockAcrossInstallerHandoff(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
pending := prepareLegacyWindowsUpdate(t, installDir, "v2")
var events []string
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error {
events = append(events, "wait")
return nil
}
runInstallerFn = func(string, string) error {
events = append(events, "installer")
return nil
}
startRelaunchFn = func(string, string) error {
events = append(events, "relaunch")
return nil
}
claimPendingFileUpdateFn = func(toVersion, createdAt, transactionID, launcherPath string, paths []string, _ time.Duration) (*repair.UpdateTransaction, func(), error) {
events = append(events, "claim:"+strings.Join([]string{toVersion, createdAt, transactionID, launcherPath, strings.Join(paths, "\x00")}, "\x01"))
return pending, func() { events = append(events, "release") }, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
events = append(events, "publish")
return true, []repair.FileUpdateInstallReceipt{}, nil
}
recordInstalledUpdateFn = func(tx *repair.UpdateTransaction, _ ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
events = append(events, "record-installed")
return tx, nil
}
stageInstallerFn = func(path, sha256 string) (string, func() error, error) {
events = append(events, "stage-installer:"+sha256)
return path + ".claimed", func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
events = append(events, "claim-installer")
return func() { events = append(events, "release-installer") }, nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", pending.ToVersion,
"--created-at", pending.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(pending),
}); code != 0 {
t.Fatalf("run exit code = %d", code)
}
wantClaim := "claim:" + strings.Join([]string{
pending.ToVersion,
pending.CreatedAt,
repair.UpdateTransactionID(pending),
filepath.Join(installDir, "reasonix-desktop.exe"),
strings.Join(windowsReleaseUnitPaths(installDir), "\x00"),
}, "\x01")
if len(events) != 10 || events[0] != "wait" || events[1] != wantClaim ||
events[2] != "stage-installer:"+testInstallerSHA256 ||
events[3] != "claim-installer" || events[4] != "installer" ||
events[5] != "release-installer" || events[6] != "publish" ||
events[7] != "record-installed" || events[8] != "relaunch" ||
events[9] != "release" {
t.Fatalf("handoff events = %#v", events)
}
}
func TestRunDoesNotClaimUpdateBeforeParentExits(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
})
waitForProcessExitFn = func(uint32, time.Duration) error {
return errors.New("parent still running")
}
claimed := false
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
claimed = true
return &repair.UpdateTransaction{}, func() {}, nil
}
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--to-version", "v2",
"--created-at", "2026-07-29T00:00:00Z",
"--transaction-id", "transaction-1",
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if claimed || installerRan {
t.Fatalf("claim=%v installer=%v before parent exit", claimed, installerRan)
}
}
func TestRunRelaunchesWhenLegacyPendingCannotBeClaimed(t *testing.T) {
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
relaunched := false
startRelaunchFn = func(path, dir string) error {
relaunched = path == filepath.Join(installDir, "reasonix-desktop.exe") && dir == installDir
return nil
}
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return nil, nil, errors.New("pending lock unavailable")
}
if code := run([]string{
"--parent-pid", "1234",
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", "2026-08-02T00:00:00Z",
"--transaction-id", strings.Repeat("a", 64),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch the existing desktop")
}
}
func TestRunCancelsTransactionWhenStagedExtractionFails(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalInstallStaged := installStagedReleaseUnitFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
installStagedReleaseUnitFn = originalInstallStaged
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return errors.New("installer failed") }
startRelaunchFn = func(string, string) error { return nil }
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if _, ok := repair.ReadUpdateApplyFailure(); ok {
t.Fatal("staging-only installer failure left a recovery marker despite no live publish")
}
}
func TestRunTreatsInstalledReleaseUnitRecordingFailureAsApplyFailure(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalRecord := recordInstalledUpdateFn
originalStageInstaller := stageInstallerFn
originalClaimInstallerExecution := claimInstallerExecutionFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
recordInstalledUpdateFn = originalRecord
stageInstallerFn = originalStageInstaller
claimInstallerExecutionFn = originalClaimInstallerExecution
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
runInstallerFn = func(string, string) error { return nil }
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() {}, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
claimInstallerExecutionFn = func(string, string) (func(), error) {
return func() {}, nil
}
installStagedReleaseUnitFn = func(*repair.UpdateTransaction, string) (bool, []repair.FileUpdateInstallReceipt, error) {
return true, []repair.FileUpdateInstallReceipt{{
UpdateTransactionID: repair.UpdateTransactionID(claimed),
TargetPath: claimed.TargetPath,
InstalledStateID: "installed",
}}, nil
}
recordInstalledUpdateFn = func(*repair.UpdateTransaction, ...repair.FileUpdateInstallReceipt) (*repair.UpdateTransaction, error) {
return nil, errors.New("release unit drifted")
}
transactionID := repair.UpdateTransactionID(claimed)
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", "v2",
"--created-at", claimed.CreatedAt,
"--transaction-id", transactionID,
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if !relaunched {
t.Fatal("helper did not relaunch through recovery after post-install verification failure")
}
failure, ok := repair.ReadUpdateApplyFailure()
if !ok || failure.UpdateTransactionID != transactionID {
t.Fatalf("failure marker = %+v, present=%v", failure, ok)
}
}
func TestRunRelaunchesWhenStagingIdentityCannotBeBound(t *testing.T) {
t.Setenv("REASONIX_HOME", tempdir.New(t))
installDir := tempdir.New(t)
originalWait := waitForProcessExitFn
originalInstaller := runInstallerFn
originalRelaunch := startRelaunchFn
originalClaim := claimPendingFileUpdateFn
originalStageInstaller := stageInstallerFn
originalLstatStaging := lstatUpdateStagingFn
t.Cleanup(func() {
waitForProcessExitFn = originalWait
runInstallerFn = originalInstaller
startRelaunchFn = originalRelaunch
claimPendingFileUpdateFn = originalClaim
stageInstallerFn = originalStageInstaller
lstatUpdateStagingFn = originalLstatStaging
})
waitForProcessExitFn = func(uint32, time.Duration) error { return nil }
installerRan := false
runInstallerFn = func(string, string) error {
installerRan = true
return nil
}
relaunched := false
startRelaunchFn = func(string, string) error {
relaunched = true
return nil
}
claimed := prepareLegacyWindowsUpdate(t, installDir, "v2")
releases := 0
claimPendingFileUpdateFn = func(string, string, string, string, []string, time.Duration) (*repair.UpdateTransaction, func(), error) {
return claimed, func() { releases++ }, nil
}
stageInstallerFn = func(path, _ string) (string, func() error, error) {
return path, func() error { return nil }, nil
}
lstatUpdateStagingFn = func(string) (os.FileInfo, error) {
return nil, errors.New("staging identity unavailable")
}
if code := run([]string{
"--installer", filepath.Join(installDir, "installer.exe"),
"--installer-sha256", testInstallerSHA256,
"--install-dir", installDir,
"--relaunch", filepath.Join(installDir, "reasonix-desktop.exe"),
"--to-version", claimed.ToVersion,
"--created-at", claimed.CreatedAt,
"--transaction-id", repair.UpdateTransactionID(claimed),
}); code != 1 {
t.Fatalf("run exit code = %d, want 1", code)
}
if installerRan {
t.Fatal("installer ran without a bound staging directory")
}
if !relaunched {
t.Fatal("helper did not relaunch after staging identity failure")
}
if releases == 0 {
t.Fatal("helper did not release the update claim")
}
}
func TestClaimVerifiedInstallerForExecutionFreezesPath(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
content := []byte("verified-installer")
if err := os.WriteFile(path, content, 0o700); err != nil {
t.Fatal(err)
}
sum := sha256.Sum256(content)
release, err := claimVerifiedInstallerForExecution(path, hex.EncodeToString(sum[:]))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("tampered"), 0o700); err == nil {
release()
t.Fatal("installer remained writable while execution claim was held")
}
if err := os.Rename(path, path+".replaced"); err == nil {
release()
t.Fatal("installer remained renameable while execution claim was held")
}
release()
release()
if err := os.WriteFile(path, []byte("replacement"), 0o700); err != nil {
t.Fatalf("installer remained frozen after claim release: %v", err)
}
}
func TestClaimVerifiedInstallerForExecutionRejectsHashDrift(t *testing.T) {
path := filepath.Join(tempdir.New(t), "installer.exe")
if err := os.WriteFile(path, []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if release, err := claimVerifiedInstallerForExecution(path, testInstallerSHA256); err == nil {
release()
t.Fatal("installer with the wrong SHA-256 received an execution claim")
}
}
-238
View File
@@ -1,238 +0,0 @@
package main
import (
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
const maxWindowsPayloadMetadataSize = 64 << 10
var verifyWindowsPayloadManifestFn = update.Verify
type stagedFileUpdateMember struct {
targetPath string
content []byte
mode os.FileMode
}
// loadWindowsStagedReleaseUnit validates and reads the complete NSIS payload
// before any live release-unit member is moved. An existing Reasonix.exe is the
// portable alias of reasonix-launcher.exe and reuses those staged bytes; an
// installed package that did not have the alias remains unchanged.
func loadWindowsStagedReleaseUnit(claimed *repair.UpdateTransaction, stagingDir string) ([]stagedFileUpdateMember, error) {
if claimed == nil || claimed.TargetKind != "file" || len(claimed.Files) == 0 {
return nil, fmt.Errorf("load staged release unit: transaction identity is incomplete")
}
if err := validateWindowsClaimedReleaseUnit(claimed); err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
if stagingDir == "" || stagingDir == "." || !filepath.IsAbs(stagingDir) {
return nil, fmt.Errorf("load staged release unit: staging directory is invalid")
}
info, err := os.Lstat(stagingDir)
if err != nil {
return nil, fmt.Errorf("load staged release unit: inspect staging directory: %w", err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return nil, fmt.Errorf("load staged release unit: staging path is not a directory")
}
hashes, err := loadWindowsPayloadManifest(stagingDir, claimed.ToVersion)
if err != nil {
return nil, fmt.Errorf("load staged release unit: %w", err)
}
contents := make(map[string][]byte)
members := make([]stagedFileUpdateMember, 0, len(claimed.Files))
seenTargets := make(map[string]struct{}, len(claimed.Files))
for _, file := range claimed.Files {
targetPath := filepath.Clean(strings.TrimSpace(file.TargetPath))
targetKey := strings.ToLower(targetPath)
if targetPath == "" || targetPath == "." {
return nil, fmt.Errorf("load staged release unit: target path is invalid")
}
if _, ok := seenTargets[targetKey]; ok {
return nil, fmt.Errorf("load staged release unit: duplicate target %s", filepath.Base(targetPath))
}
seenTargets[targetKey] = struct{}{}
if strings.EqualFold(filepath.Base(targetPath), "Reasonix.exe") && file.MissingBefore {
continue
}
sourceName, err := windowsStagedSourceName(filepath.Base(targetPath))
if err != nil {
return nil, err
}
sourcePath := filepath.Join(stagingDir, sourceName)
content, ok := contents[sourceName]
if !ok {
sourceInfo, statErr := os.Lstat(sourcePath)
if statErr != nil {
return nil, fmt.Errorf("load staged release unit: inspect %s: %w", sourceName, statErr)
}
if !sourceInfo.Mode().IsRegular() {
return nil, fmt.Errorf("load staged release unit: %s is not a regular file", sourceName)
}
content, err = readVerifiedWindowsStagedPayloadFn(sourcePath)
if err != nil {
return nil, fmt.Errorf("load staged release unit: read %s: %w", sourceName, err)
}
if !strings.EqualFold(update.WindowsPayloadSHA256(content), hashes[sourceName]) {
return nil, fmt.Errorf("load staged release unit: %s does not match the signed release manifest", sourceName)
}
contents[sourceName] = content
}
members = append(members, stagedFileUpdateMember{
targetPath: targetPath,
content: content,
mode: 0o700,
})
}
// Publish the running desktop last. If an earlier member fails, the old
// desktop remains the executable entry point that can report/retry recovery.
sort.SliceStable(members, func(i, j int) bool {
iPrimary := strings.EqualFold(members[i].targetPath, claimed.TargetPath)
jPrimary := strings.EqualFold(members[j].targetPath, claimed.TargetPath)
return !iPrimary && jPrimary
})
return members, nil
}
func validateWindowsClaimedReleaseUnit(claimed *repair.UpdateTransaction) error {
if claimed == nil ||
!strings.EqualFold(filepath.Base(claimed.TargetPath), "reasonix-desktop.exe") {
return fmt.Errorf("claimed release unit primary executable is invalid")
}
required := map[string]bool{
"reasonix-desktop.exe": false,
"reasonix-guard.exe": false,
"reasonix-launcher.exe": false,
"reasonix-update-helper.exe": false,
"reasonix-cli.exe": false,
"reasonix.exe": false,
}
installDir := filepath.Clean(filepath.Dir(claimed.TargetPath))
for _, file := range claimed.Files {
target := filepath.Clean(strings.TrimSpace(file.TargetPath))
if target == "" || target == "." ||
!strings.EqualFold(filepath.Dir(target), installDir) {
return fmt.Errorf("claimed release unit target is outside the installation directory")
}
name := strings.ToLower(filepath.Base(target))
seen, ok := required[name]
if !ok {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
if seen {
return fmt.Errorf("claimed release unit contains a duplicate target")
}
required[name] = true
}
for name, seen := range required {
if !seen {
return fmt.Errorf("claimed release unit omits %s", name)
}
}
if len(claimed.Files) != len(required) {
return fmt.Errorf("claimed release unit contains an unexpected target")
}
return nil
}
func loadWindowsPayloadManifest(stagingDir, expectedVersion string) (map[string]string, error) {
manifest, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadManifestName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest: %w", err)
}
signature, err := readWindowsPayloadMetadata(filepath.Join(stagingDir, update.WindowsPayloadSignatureName))
if err != nil {
return nil, fmt.Errorf("read signed release manifest signature: %w", err)
}
if err := verifyWindowsPayloadManifestFn(manifest, signature); err != nil {
return nil, fmt.Errorf("verify signed release manifest: %w", err)
}
hashes, err := update.DecodeWindowsPayloadManifest(manifest, expectedVersion)
if err != nil {
return nil, err
}
return hashes, nil
}
func readWindowsPayloadMetadata(path string) ([]byte, error) {
pathInfo, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !pathInfo.Mode().IsRegular() {
return nil, fmt.Errorf("%s is not a regular file", filepath.Base(path))
}
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
info, err := file.Stat()
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s is not a bounded regular file", filepath.Base(path))
}
if !os.SameFile(pathInfo, info) {
return nil, fmt.Errorf("%s changed before it was opened", filepath.Base(path))
}
data, err := io.ReadAll(io.LimitReader(file, maxWindowsPayloadMetadataSize+1))
if err != nil {
return nil, err
}
if len(data) == 0 || len(data) > maxWindowsPayloadMetadataSize {
return nil, fmt.Errorf("%s changed size while it was read", filepath.Base(path))
}
return data, nil
}
func windowsStagedSourceName(targetBase string) (string, error) {
switch strings.ToLower(strings.TrimSpace(targetBase)) {
case "reasonix-desktop.exe":
return "reasonix-desktop.exe", nil
case "reasonix-guard.exe":
return "reasonix-guard.exe", nil
case "reasonix-launcher.exe":
return "reasonix-launcher.exe", nil
case "reasonix-update-helper.exe":
return "reasonix-update-helper.exe", nil
case "reasonix-cli.exe":
return "reasonix-cli.exe", nil
case "reasonix.exe":
return "reasonix-launcher.exe", nil
default:
return "", fmt.Errorf("load staged release unit: unsupported target %q", targetBase)
}
}
func publishLoadedFileUpdateReleaseUnit(
claimed *repair.UpdateTransaction,
members []stagedFileUpdateMember,
publish func(*repair.UpdateTransaction, string, []byte, os.FileMode) (repair.FileUpdateInstallReceipt, error),
) ([]repair.FileUpdateInstallReceipt, error) {
if publish == nil || len(members) == 0 {
return nil, fmt.Errorf("publish staged release unit: payload is incomplete")
}
receipts := make([]repair.FileUpdateInstallReceipt, 0, len(members))
for _, member := range members {
receipt, err := publish(claimed, member.targetPath, member.content, member.mode)
if err != nil {
return receipts, fmt.Errorf("publish staged release unit %s: %w", filepath.Base(member.targetPath), err)
}
receipts = append(receipts, receipt)
}
return receipts, nil
}
@@ -1,377 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/base/tempdir"
"reasonix/internal/platform/repair"
"reasonix/internal/platform/update"
)
func TestLoadWindowsStagedReleaseUnitPreflightsAllMembersAndPublishesDesktopLast(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe")},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if got := filepath.Base(members[len(members)-1].targetPath); !strings.EqualFold(got, "reasonix-desktop.exe") {
t.Fatalf("last published member = %q, want desktop", got)
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, content []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, filepath.Base(target)+"="+string(content))
return repair.FileUpdateInstallReceipt{TargetPath: target}, nil
})
if err != nil {
t.Fatal(err)
}
if len(receipts) != len(members) {
t.Fatalf("publish receipts = %d, want %d", len(receipts), len(members))
}
if got := strings.Join(published, ","); !strings.Contains(got, "Reasonix.exe=launcher-v2") {
t.Fatalf("portable alias did not reuse launcher payload: %s", got)
}
if !strings.HasPrefix(published[len(published)-1], "reasonix-desktop.exe=") {
t.Fatalf("publish order = %v", published)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsIncompletePayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-desktop.exe"), []byte("desktop-v2"), 0o700); err != nil {
t.Fatal(err)
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
},
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("incomplete staged release unit was accepted")
}
}
func TestValidateWindowsClaimedReleaseUnitRequiresExactTargets(t *testing.T) {
_, complete := completeWindowsStagedReleaseUnitForTest(t, "v2")
for i, file := range complete.Files {
t.Run("missing-"+strings.ToLower(filepath.Base(file.TargetPath)), func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files[:i]...)
claimed.Files = append(claimed.Files, complete.Files[i+1:]...)
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "omits") {
t.Fatalf("missing target error = %v", err)
}
})
}
t.Run("extra", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...),
repair.UpdateTransactionFile{TargetPath: filepath.Join(filepath.Dir(complete.TargetPath), "other.exe")})
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "unexpected") {
t.Fatalf("extra target error = %v", err)
}
})
t.Run("duplicate", func(t *testing.T) {
claimed := *complete
claimed.Files = append(append([]repair.UpdateTransactionFile(nil), complete.Files...), complete.Files[0])
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "duplicate") {
t.Fatalf("duplicate target error = %v", err)
}
})
t.Run("outside", func(t *testing.T) {
claimed := *complete
claimed.Files = append([]repair.UpdateTransactionFile(nil), complete.Files...)
claimed.Files[1].TargetPath = filepath.Join(tempdir.New(t), filepath.Base(claimed.Files[1].TargetPath))
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "outside") {
t.Fatalf("outside target error = %v", err)
}
})
t.Run("primary", func(t *testing.T) {
claimed := *complete
claimed.TargetPath = filepath.Join(filepath.Dir(complete.TargetPath), "reasonix-guard.exe")
if err := validateWindowsClaimedReleaseUnit(&claimed); err == nil ||
!strings.Contains(err.Error(), "primary") {
t.Fatalf("primary target error = %v", err)
}
})
}
func TestLoadWindowsStagedReleaseUnitDoesNotCreateMissingPortableAlias(t *testing.T) {
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
content := "payload:" + name
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
useTestWindowsPayloadManifest(t, staging, "v2")
installDir := tempdir.New(t)
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v2",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: []repair.UpdateTransactionFile{
{TargetPath: filepath.Join(installDir, "reasonix-desktop.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-guard.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-launcher.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-update-helper.exe")},
{TargetPath: filepath.Join(installDir, "reasonix-cli.exe")},
{TargetPath: filepath.Join(installDir, "Reasonix.exe"), MissingBefore: true},
},
}
members, err := loadWindowsStagedReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
for _, member := range members {
if strings.EqualFold(filepath.Base(member.targetPath), "Reasonix.exe") {
t.Fatalf("missing portable alias was added to publish set: %+v", members)
}
}
}
func TestPublishLoadedFileUpdateReleaseUnitStopsOnFirstFailedCompareAndPublish(t *testing.T) {
claimed := &repair.UpdateTransaction{TargetKind: "file"}
members := []stagedFileUpdateMember{
{targetPath: "guard.exe", content: []byte("guard"), mode: 0o700},
{targetPath: "desktop.exe", content: []byte("desktop"), mode: 0o700},
}
var published []string
receipts, err := publishLoadedFileUpdateReleaseUnit(claimed, members, func(_ *repair.UpdateTransaction, target string, _ []byte, _ os.FileMode) (repair.FileUpdateInstallReceipt, error) {
published = append(published, target)
return repair.FileUpdateInstallReceipt{}, errors.New("concurrent recreation")
})
if err == nil || !strings.Contains(err.Error(), "concurrent recreation") {
t.Fatalf("publish error = %v", err)
}
if len(published) != 1 {
t.Fatalf("published members = %v, want one attempted member", published)
}
if len(receipts) != 0 {
t.Fatalf("failed first publish returned receipts: %+v", receipts)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsUnverifiedPayloadBeforePublish(t *testing.T) {
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-launcher.exe",
"reasonix-update-helper.exe",
"reasonix-cli.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(string) ([]byte, error) {
return nil, errors.New("payload signature rejected")
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil {
t.Fatal("unsigned staged payload must be rejected before publication")
}
}
func TestLoadWindowsStagedReleaseUnitReadsEachSourceThroughVerifier(t *testing.T) {
staging := tempdir.New(t)
for name, content := range map[string]string{
"reasonix-desktop.exe": "desktop-v2",
"reasonix-guard.exe": "guard-v2",
"reasonix-launcher.exe": "launcher-v2",
"reasonix-update-helper.exe": "helper-v2",
"reasonix-cli.exe": "cli-v2",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(content), 0o700); err != nil {
t.Fatal(err)
}
}
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
_, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
var verified []string
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = func(path string) ([]byte, error) {
verified = append(verified, filepath.Base(path))
return os.ReadFile(path)
}
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err != nil {
t.Fatal(err)
}
if len(verified) != len(update.WindowsPayloadFileNames()) {
t.Fatalf("verified payload count = %d, want %d (%v)", len(verified), len(update.WindowsPayloadFileNames()), verified)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsMissingManifest(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), update.WindowsPayloadManifestName) {
t.Fatalf("missing signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsBadManifestSignature(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "verify signed release manifest") {
t.Fatalf("bad signed manifest error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestVersionDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v3")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "identity does not match") {
t.Fatalf("release version drift error = %v", err)
}
}
func TestLoadWindowsStagedReleaseUnitRejectsManifestMemberHashDrift(t *testing.T) {
staging, claimed := completeWindowsStagedReleaseUnitForTest(t, "v2")
writeWindowsPayloadManifestForTest(t, staging, "v2")
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
if err := os.WriteFile(filepath.Join(staging, "reasonix-guard.exe"), []byte("tampered"), 0o700); err != nil {
t.Fatal(err)
}
if _, err := loadWindowsStagedReleaseUnit(claimed, staging); err == nil ||
!strings.Contains(err.Error(), "does not match the signed release manifest") {
t.Fatalf("release member drift error = %v", err)
}
}
func completeWindowsStagedReleaseUnitForTest(t *testing.T, version string) (string, *repair.UpdateTransaction) {
t.Helper()
staging := tempdir.New(t)
for _, name := range update.WindowsPayloadFileNames() {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
installDir := tempdir.New(t)
files := make([]repair.UpdateTransactionFile, 0, len(update.WindowsPayloadFileNames())+1)
for _, name := range update.WindowsPayloadFileNames() {
files = append(files, repair.UpdateTransactionFile{TargetPath: filepath.Join(installDir, name)})
}
files = append(files, repair.UpdateTransactionFile{
TargetPath: filepath.Join(installDir, "Reasonix.exe"),
MissingBefore: true,
})
return staging, &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: version,
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
Files: files,
}
}
func writeWindowsPayloadManifestForTest(t *testing.T, staging, version string) {
t.Helper()
hashes := make(map[string]string)
for _, name := range update.WindowsPayloadFileNames() {
content, err := os.ReadFile(filepath.Join(staging, name))
if os.IsNotExist(err) {
content = []byte("missing:" + name)
} else if err != nil {
t.Fatal(err)
}
hashes[name] = update.WindowsPayloadSHA256(content)
}
manifest, err := update.EncodeWindowsPayloadManifest(version, hashes)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadManifestName), manifest, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staging, update.WindowsPayloadSignatureName), []byte("test signature"), 0o600); err != nil {
t.Fatal(err)
}
}
func acceptWindowsPayloadManifestForTest(t *testing.T) {
t.Helper()
original := verifyWindowsPayloadManifestFn
verifyWindowsPayloadManifestFn = func(_, signature []byte) error {
if string(signature) != "test signature" {
return errors.New("unexpected test signature")
}
return nil
}
t.Cleanup(func() { verifyWindowsPayloadManifestFn = original })
}
func useTestWindowsPayloadManifest(t *testing.T, staging, version string) {
t.Helper()
writeWindowsPayloadManifestForTest(t, staging, version)
acceptWindowsPayloadManifestForTest(t)
useUnverifiedPayloadReaderForTest(t)
}
func useUnverifiedPayloadReaderForTest(t *testing.T) {
t.Helper()
original := readVerifiedWindowsStagedPayloadFn
readVerifiedWindowsStagedPayloadFn = os.ReadFile
t.Cleanup(func() { readVerifiedWindowsStagedPayloadFn = original })
}
@@ -1,118 +0,0 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
RootMembers: []installlayout.Member{
{Name: "reasonix-launcher.exe", Path: launcherSrc, Mode: 0o700},
{Name: "Reasonix.exe", Path: launcherSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
},
RequiredRootNames: []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"},
}); err != nil {
return err
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -1,131 +0,0 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/platform/installlayout"
"reasonix/internal/platform/repair"
"reasonix/internal/base/tempdir"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := tempdir.New(t)
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := tempdir.New(t)
staging := tempdir.New(t)
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+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()
@@ -90,6 +90,8 @@ void clipboardSettle() {
Json paste(DWORD pid, const std::string& text) {
front(pid);
Landing at = landing(pid, true);
refuseHeldText(at);
clipboardSettle();
if (!open()) throw Failure{"computer.failed", "another application is holding the clipboard"};
std::vector<Saved> saved = save();
@@ -112,5 +114,5 @@ Json paste(DWORD pid, const std::string& text) {
// The application reads the clipboard when it gets round to the keystroke,
// and giving it back before then hands it the person's clipboard instead.
std::this_thread::sleep_for(std::chrono::milliseconds(250));
return Json::object().set("pasted", static_cast<unsigned long long>(w.size()));
return typedEffect(at, w);
}
+24
View File
@@ -4,6 +4,7 @@
#define NOMINMAX
#include <windows.h>
#include <functional>
#include <string>
#include <vector>
@@ -14,6 +15,7 @@
struct Failure {
std::string code;
std::string message;
Json blockedBy; // the modal that would have taken the input instead
};
std::string narrow(const std::wstring& w);
@@ -44,7 +46,11 @@ bool appListed(HWND top);
void requireOperable(DWORD pid);
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);
@@ -65,6 +71,24 @@ Json scroll(DWORD pid, const std::string& ref, double amount);
Json focus(DWORD pid, const std::string& ref);
Json setValue(DWORD pid, const std::string& ref, const std::string& text);
// Landing is where keyboard input to an application goes, read after it is
// brought forward and before anything is sent: the modal holding it, if any,
// and, when withValue, the focused element with what it holds now.
struct Landing {
Json modal;
bool takesText = false;
bool readable = false;
std::wstring before;
std::function<bool(std::wstring&)> read;
};
Landing landing(DWORD pid, bool withValue);
// refuseHeldText refuses text that would land in a modal on an element that
// takes none, where a letter can be a dialog's mnemonic.
void refuseHeldText(const Landing& at);
// typedEffect grades typing on the focused element read again afterwards.
Json typedEffect(const Landing& at, const std::wstring& typed);
Json effect(const char* cls, const char* evidence, const Json& modal);
// keyboard.cpp
Json typeText(DWORD pid, const std::string& text);
Json pressKey(DWORD pid, const std::string& chord, int times);
+7 -3
View File
@@ -130,6 +130,8 @@ void stillFront(DWORD pid, size_t done, size_t total, const char* unit) {
Json typeText(DWORD pid, const std::string& text) {
std::wstring units = widen(text);
front(pid);
Landing at = landing(pid, true);
refuseHeldText(at);
std::vector<INPUT> batch;
size_t chunk = 0;
for (size_t i = 0; i < units.size(); i++) {
@@ -151,12 +153,13 @@ Json typeText(DWORD pid, const std::string& text) {
}
}
send(batch);
return Json::object();
return typedEffect(at, units);
}
Json pressKey(DWORD pid, const std::string& chord, int times) {
KeyChord c = resolve(chord);
front(pid);
Json modal = landing(pid, false).modal;
std::vector<INPUT> batch;
for (int i = 0; i < std::clamp(times, 1, 200); i++) {
stillFront(pid, i, times, "presses");
@@ -164,7 +167,7 @@ Json pressKey(DWORD pid, const std::string& chord, int times) {
chordUp(batch, c);
send(batch);
}
return Json::object();
return effect("unverifiable", nullptr, modal);
}
// holdKey keeps a key down, which is what a game or a scrubbing control reads
@@ -172,6 +175,7 @@ Json pressKey(DWORD pid, const std::string& chord, int times) {
Json holdKey(DWORD pid, const std::string& chord, double seconds) {
KeyChord c = resolve(chord);
front(pid);
Json modal = landing(pid, false).modal;
std::vector<INPUT> batch;
chordDown(batch, c);
send(batch);
@@ -185,7 +189,7 @@ Json holdKey(DWORD pid, const std::string& chord, double seconds) {
chordUp(batch, c);
send(batch);
if (left) throw Failure{"computer.needs_front", "the application left the front during the hold; the key was released early"};
return Json::object();
return effect("unverifiable", nullptr, modal);
}
void sendChord(const std::string& chord) {
+10 -1
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"));
@@ -156,7 +163,9 @@ int main() {
try {
reply(Json::object().set("id", *id).set("result", handle(method, params)));
} catch (const Failure& f) {
reply(Json::object().set("id", *id).set("error", Json::object().set("code", f.code).set("message", f.message)));
Json error = Json::object().set("code", f.code).set("message", f.message);
if (f.blockedBy.kind() != Json::Kind::Null) error.set("blocked_by", f.blockedBy);
reply(Json::object().set("id", *id).set("error", error));
} catch (const std::exception& e) {
reply(Json::object().set("id", *id).set("error", Json::object().set("code", "computer.failed").set("message", e.what())));
}
+40 -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);
@@ -209,11 +233,21 @@ bool isFront(DWORD pid) {
return fg && contentPid(fg) == pid;
}
// heldOwner is the window of the same process that window, as a modal dialog,
// has disabled somewhere up its owner chain; null when it holds none.
HWND heldOwner(HWND window, DWORD pid) {
if (!IsWindowEnabled(window)) return nullptr;
for (HWND o = GetWindow(window, GW_OWNER); o; o = GetWindow(o, GW_OWNER)) {
if (contentPid(o) == pid && !IsWindowEnabled(o)) return o;
}
return nullptr;
}
// front brings the application forward before input that goes wherever the
// 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];
@@ -223,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)) {
@@ -232,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"};
}
+212 -9
View File
@@ -4,6 +4,7 @@
#include <UIAutomation.h>
#include <wrl/client.h>
#include <algorithm>
#include <chrono>
#include <cmath>
#include <functional>
@@ -19,6 +20,9 @@ namespace {
constexpr int maxNodes = 1500;
constexpr int maxDepth = 40;
// A value longer than this is not read back: comparing it costs more than the
// step it would grade.
constexpr int maxReadback = 1 << 16;
constexpr HRESULT elementGone = static_cast<HRESULT>(0x80040201); // UIA_E_ELEMENTNOTAVAILABLE
constexpr HRESULT elementDisabled = static_cast<HRESULT>(0x80040200); // UIA_E_ELEMENTNOTENABLED
constexpr HRESULT notSupported = static_cast<HRESULT>(0x80040204); // UIA_E_NOTSUPPORTED
@@ -137,8 +141,48 @@ bool transparent(int type) {
return type == UIA_GroupControlTypeId || type == UIA_PaneControlTypeId || type == UIA_CustomControlTypeId;
}
std::string windowTitle(HWND hwnd) {
wchar_t buf[512];
int n = GetWindowTextW(hwnd, buf, ARRAYSIZE(buf));
return narrow(std::wstring(buf, n > 0 ? n : 0));
}
HWND cachedHandle(IUIAutomationElement* el) {
return reinterpret_cast<HWND>(static_cast<intptr_t>(Cached(el, UIA_NativeWindowHandlePropertyId).integer()));
}
// inWindowModal is a dialog drawn inside its window that declares itself
// modal. A top-level window's own root does not count: one holds input only
// through the owner it has disabled.
bool inWindowModal(IUIAutomationElement* el, HWND root) {
return Cached(el, UIA_ControlTypePropertyId).integer() == UIA_WindowControlTypeId &&
Cached(el, UIA_WindowIsModalPropertyId).flag() && cachedHandle(el) != root;
}
// modalJson names a modal for the model; the titles are shown, never read.
Json modalJson(IUIAutomationElement* el, DWORD pid, const std::string& blocks) {
return Json::object().set("ref", refFor(el, pid)).set("title", Cached(el, UIA_NamePropertyId).str()).set("blocks", blocks);
}
// win32Modal is the modal dialog hwnd is, when it holds another window of
// the process; null otherwise.
Json win32Modal(HWND hwnd, DWORD pid) {
HWND held = heldOwner(hwnd, pid);
if (!held) return Json();
ComPtr<IUIAutomationElement> el;
if (FAILED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &el)) || !el) {
return Json::object().set("ref", "").set("title", windowTitle(hwnd)).set("blocks", windowTitle(held));
}
return modalJson(el.Get(), pid, windowTitle(held));
}
struct Walk {
DWORD pid;
HWND root = nullptr;
bool rootIsModal = false;
std::string rootTitle, heldTitle;
Json modals = Json::array();
std::set<std::string> modalRefs;
std::vector<std::string> lines;
std::set<std::string> seen;
int count = 0;
@@ -190,6 +234,12 @@ struct Walk {
if (Cached(el, UIA_SelectionItemIsSelectedPropertyId).flag()) line += " selected";
if (Cached(el, UIA_ToggleToggleStatePropertyId).integer() == ToggleState_On) line += " checked";
if (pressable(el)) line += " pressable";
bool isRoot = cachedHandle(el) == root;
if ((isRoot && rootIsModal) || inWindowModal(el, root)) {
line += " modal";
Json m = modalJson(el, pid, isRoot ? heldTitle : rootTitle);
if (modalRefs.insert(m.get("ref")->string()).second) modals.push(m);
}
lines.push_back(line);
children(el, depth + 1);
}
@@ -305,8 +355,148 @@ void wheel(DWORD pid, double amount) {
PostMessageW(target, WM_MOUSEWHEEL, MAKEWPARAM(0, static_cast<SHORT>(delta)), MAKELPARAM(centre.x, centre.y));
}
// nativeRoot is the top-level window an element is drawn in.
HWND nativeRoot(IUIAutomationElement* el) {
ComPtr<IUIAutomationElement> at = el;
for (int i = 0; at && i < maxDepth; i++) {
UIA_HWND h = nullptr;
if (SUCCEEDED(at->get_CurrentNativeWindowHandle(&h)) && h) return GetAncestor(static_cast<HWND>(h), GA_ROOT);
ComPtr<IUIAutomationElement> parent;
walker->GetParentElement(at.Get(), &parent);
at = parent;
}
return nullptr;
}
// reachable refuses an element in a window the application has disabled,
// which a modal dialog does to the window it holds: the application has
// declared that window takes no input until the dialog is answered.
void reachable(IUIAutomationElement* el, DWORD pid, const std::string& what) {
HWND root = nativeRoot(el);
if (!root || IsWindowEnabled(root)) return;
for (HWND w : appWindows(pid, false)) {
for (HWND o = heldOwner(w, pid) ? GetWindow(w, GW_OWNER) : nullptr; o; o = GetWindow(o, GW_OWNER)) {
if (o == root) {
throw Failure{"computer.blocked", what + " is in a window a modal dialog holds; answer or dismiss the dialog first",
win32Modal(w, pid)};
}
}
}
throw Failure{"computer.blocked", what + " is in a window the application has disabled"};
}
// typedExactly is whether after is before with typed put in at one place,
// replacing whatever was selected there. Where repeated characters make the
// changed span ambiguous it is taken from either end, and any other change,
// such as autocomplete or a single-line field dropping a newline, is not it.
bool typedExactly(const std::wstring& before, const std::wstring& after, const std::wstring& typed) {
if (after == before || after.size() < typed.size()) return false;
auto span = [&](bool prefixFirst) {
size_t p = 0, s = 0, room = std::min(before.size(), after.size());
auto prefix = [&] { while (p < room - s && before[p] == after[p]) p++; };
auto suffix = [&] { while (s < room - p && before[before.size() - 1 - s] == after[after.size() - 1 - s]) s++; };
if (prefixFirst) {
prefix();
suffix();
} else {
suffix();
prefix();
}
return after.substr(p, after.size() - p - s);
};
return span(true) == typed || span(false) == typed;
}
std::wstring flat(std::wstring s) {
s.erase(std::remove(s.begin(), s.end(), L'\r'), s.end());
return s;
}
// readValue is what an element holds as text, for comparing before and after
// a step. A password, or a value too long to compare, is not read.
bool readValue(IUIAutomationElement* el, std::wstring& out) {
BOOL password = FALSE;
if (FAILED(el->get_CurrentIsPassword(&password)) || password) return false;
BSTR v = nullptr;
if (auto p = pattern<IUIAutomationValuePattern>(el, UIA_ValuePatternId)) {
if (FAILED(p->get_CurrentValue(&v))) return false;
} else if (auto t = pattern<IUIAutomationTextPattern>(el, UIA_TextPatternId)) {
ComPtr<IUIAutomationTextRange> doc;
if (FAILED(t->get_DocumentRange(&doc)) || !doc || FAILED(doc->GetText(maxReadback, &v))) return false;
} else {
return false;
}
out = flat(v ? std::wstring(v, SysStringLen(v)) : std::wstring());
SysFreeString(v);
return out.size() < static_cast<size_t>(maxReadback);
}
} // namespace
Json effect(const char* cls, const char* evidence, const Json& modal) {
Json e = Json::object().set("class", cls);
if (evidence) {
Json kinds = Json::array();
kinds.push(evidence);
e.set("evidence", kinds);
}
if (modal.kind() != Json::Kind::Null) e.set("blocked_by", modal);
return Json::object().set("effect", e);
}
Landing landing(DWORD pid, bool withValue) {
need();
Landing at;
ComPtr<IUIAutomationElement> focused;
int owner = 0;
if (FAILED(uia->GetFocusedElementBuildCache(cache.Get(), &focused)) || !focused || FAILED(focused->get_CurrentProcessId(&owner)) ||
static_cast<DWORD>(owner) != pid) {
focused = nullptr;
}
HWND root = focused ? nativeRoot(focused.Get()) : nullptr;
if (!root && isFront(pid)) root = GetForegroundWindow();
ComPtr<IUIAutomationElement> el = focused;
for (int i = 0; el && i < maxDepth && at.modal.kind() == Json::Kind::Null; i++) {
if (inWindowModal(el.Get(), root)) at.modal = modalJson(el.Get(), pid, windowTitle(root));
ComPtr<IUIAutomationElement> parent;
walker->GetParentElementBuildCache(el.Get(), cache.Get(), &parent);
el = parent;
}
if (at.modal.kind() == Json::Kind::Null && root) at.modal = win32Modal(root, pid);
if (!focused || !withValue) return at;
if (Cached(focused.Get(), UIA_IsValuePatternAvailablePropertyId).flag()) {
at.takesText = !Cached(focused.Get(), UIA_ValueIsReadOnlyPropertyId).flag();
} else {
int type = Cached(focused.Get(), UIA_ControlTypePropertyId).integer();
at.takesText = type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId;
}
at.read = [focused](std::wstring& out) { return readValue(focused.Get(), out); };
at.readable = at.read(at.before);
return at;
}
void refuseHeldText(const Landing& at) {
if (at.modal.kind() == Json::Kind::Null || at.takesText) return;
throw Failure{"computer.blocked",
"a modal dialog holds this application's input and its focused element takes no text, where a letter can answer the "
"dialog; answer or dismiss it first",
at.modal};
}
// The application takes typed characters one message at a time, so a value
// read part-way through holds some of them; it is read again until all are in.
Json typedEffect(const Landing& at, const std::wstring& typed) {
if (!at.readable) return effect("unverifiable", nullptr, at.modal);
std::wstring after, want = flat(typed);
for (int i = 0; i < 10; i++) {
if (!at.read(after)) return effect("unverifiable", nullptr, at.modal);
if (typedExactly(at.before, after, want)) return effect("confirmed", "value_readback", at.modal);
std::this_thread::sleep_for(std::chrono::milliseconds(40));
}
if (after == at.before) return effect("suspected_noop", "value_unchanged", at.modal);
return effect("unverifiable", nullptr, at.modal);
}
void uiaStart() {
if (FAILED(CoCreateInstance(__uuidof(CUIAutomation8), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia))) &&
FAILED(CoCreateInstance(__uuidof(CUIAutomation), nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&uia)))) {
@@ -324,7 +514,9 @@ void uiaStart() {
UIA_IsPasswordPropertyId, UIA_HelpTextPropertyId, UIA_HasKeyboardFocusPropertyId, UIA_IsEnabledPropertyId,
UIA_SelectionItemIsSelectedPropertyId, UIA_ToggleToggleStatePropertyId, UIA_IsInvokePatternAvailablePropertyId,
UIA_IsTogglePatternAvailablePropertyId, UIA_IsExpandCollapsePatternAvailablePropertyId,
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId}) {
UIA_IsSelectionItemPatternAvailablePropertyId, UIA_LegacyIAccessibleDefaultActionPropertyId,
UIA_WindowIsModalPropertyId, UIA_NativeWindowHandlePropertyId, UIA_IsValuePatternAvailablePropertyId,
UIA_ValueIsReadOnlyPropertyId}) {
cache->AddProperty(id);
}
}
@@ -340,6 +532,11 @@ Json snapshot(DWORD pid) {
: "This application has no window on screen right now — it may be minimized. A type, key or pointer step brings it forward; take a new snapshot after.";
}
for (HWND hwnd : wins) {
walk.root = hwnd;
HWND held = heldOwner(hwnd, pid);
walk.rootIsModal = held != nullptr;
walk.rootTitle = windowTitle(hwnd);
walk.heldTitle = held ? windowTitle(held) : "";
ComPtr<IUIAutomationElement> root;
if (SUCCEEDED(uia->ElementFromHandleBuildCache(hwnd, cache.Get(), &root)) && root) walk.visit(root.Get(), 0);
}
@@ -350,12 +547,14 @@ Json snapshot(DWORD pid) {
.set("bundle", identity(pid))
.set("lines", lines)
.set("truncated", walk.count >= maxNodes)
.set("note", note);
.set("note", note)
.set("modals", walk.modals);
}
Json press(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
activate(el.Get(), ref);
return Json::object();
@@ -383,6 +582,7 @@ Json clickAt(DWORD pid, double x, double y) {
}
int type = Cached(el.Get(), UIA_ControlTypePropertyId).integer();
std::string what = "the " + roleName(type) + " at that point";
reachable(el.Get(), pid, what);
if (pressable(el.Get())) {
activate(el.Get(), what);
} else if (type == UIA_EditControlTypeId || type == UIA_ComboBoxControlTypeId || type == UIA_DocumentControlTypeId) {
@@ -398,6 +598,7 @@ Json clickAt(DWORD pid, double x, double y) {
Json menu(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> target = element(ref, pid);
reachable(target.Get(), pid, ref);
showAt(target.Get());
ComPtr<IUIAutomationElement3> el;
if (FAILED(target.As(&el))) throw Failure{"computer.no_action", ref + " has no context menu"};
@@ -412,6 +613,7 @@ Json scroll(DWORD pid, const std::string& ref, double amount) {
ComPtr<IUIAutomationElement> from;
if (!ref.empty()) {
from = element(ref, pid);
reachable(from.Get(), pid, ref);
showAt(from.Get());
if (amount == 0) {
BOOL offscreen = TRUE;
@@ -445,6 +647,7 @@ Json scroll(DWORD pid, const std::string& ref, double amount) {
Json focus(DWORD pid, const std::string& ref) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
HRESULT hr = el->SetFocus();
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " cannot take focus (HRESULT " + hresult(hr) + ")"};
@@ -455,22 +658,22 @@ Json focus(DWORD pid, const std::string& ref) {
Json setValue(DWORD pid, const std::string& ref, const std::string& value) {
need();
ComPtr<IUIAutomationElement> el = element(ref, pid);
reachable(el.Get(), pid, ref);
showAt(el.Get());
auto p = pattern<IUIAutomationValuePattern>(el.Get(), UIA_ValuePatternId);
BOOL readOnly = FALSE;
if (!p || (SUCCEEDED(p->get_CurrentIsReadOnly(&readOnly)) && readOnly)) {
throw Failure{"computer.no_action", ref + " has no value that can be set"};
}
std::wstring before, now;
bool hadBefore = readValue(el.Get(), before);
BSTR b = SysAllocString(widen(value).c_str());
HRESULT hr = p->SetValue(b);
SysFreeString(b);
if (FAILED(hr) && hr != elementGone) throw Failure{"computer.no_action", ref + " has no value that can be set (HRESULT " + hresult(hr) + ")"};
settle(hr, ref, "set value");
BSTR now = nullptr;
p->get_CurrentValue(&now);
std::string result = now ? narrow(std::wstring(now, SysStringLen(now))) : "";
SysFreeString(now);
BOOL password = FALSE;
el->get_CurrentIsPassword(&password);
return Json::object().set("value", password ? "" : result);
if (!readValue(el.Get(), now)) return effect("unverifiable", nullptr, Json());
if (now == flat(widen(value))) return effect("confirmed", "value_readback", Json());
if (hadBefore && now == before) return effect("suspected_noop", "value_unchanged", Json());
return effect("unverifiable", nullptr, Json());
}
+9
View File
@@ -4,5 +4,14 @@
"x64": "amd64",
"arm64": "arm64",
"universal": "universal"
},
"_fpm": "fpm spells the architecture its own way in an .rpm name (x86_64, aarch64) wherever artifactName uses ${arch}; the deb comes out as amd64 already. Each spelling is renamed to the release arch, and one not listed here fails the build.",
"fpm": {
"x64": [
"x86_64"
],
"arm64": [
"aarch64"
]
}
}
+20
View File
@@ -96,6 +96,7 @@ mac:
linux:
target:
- deb
- rpm
icon: assets/icon.png
category: Development
maintainer: esengine <[email protected]>
@@ -129,3 +130,22 @@ deb:
- root
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
rpm:
# The same package name as the deb, so a distribution's package database
# names one product under one identifier.
packageName: reasonix-studio
# electron-builder's own script removes the command link whatever $1 says, and
# rpm runs it for the old package after the new one installed its link.
afterRemove: ../packaging/linux/rpm-after-remove.sh
# The same two system-path files as the deb, for the same reasons, with the
# owner stated for the same reason: the rpm spec records whoever ran the build
# unless told, and polkit will not execute a helper its invoker could have
# written.
fpm:
- --rpm-user
- root
- --rpm-group
- root
- linux/reasonix-studio-update-helper=/usr/lib/reasonix-studio/reasonix-studio-update-helper
- ../packaging/linux/io.reasonix.studio.update.policy=/usr/share/polkit-1/actions/io.reasonix.studio.update.policy
+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": {
+7 -2
View File
@@ -1,6 +1,6 @@
"use strict";
const path = require("node:path");
const { map } = require("../arch.json");
const { map, fpm } = require("../arch.json");
// What each artifact was built for, keyed by the path electron-builder wrote.
// Taken from the Arch enum it hands the hook rather than read back out of the
@@ -26,4 +26,9 @@ function canonical(arch) {
return to;
}
module.exports = { built, record, canonical, map };
// Every spelling electron-builder or fpm may put in a file name for arch.
function spellings(arch) {
return [arch, ...(fpm[arch] ?? [])];
}
module.exports = { built, record, canonical, spellings, map, fpm };
@@ -1,12 +1,21 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { built, canonical, map } = require("./arch");
const { built, canonical, spellings, map, fpm } = require("./arch");
// The archs whose electron-builder spelling differs from the release one. A
// name still carrying one of these got past the rename, which is the failure
// this refuses to ship rather than report.
const foreign = Object.keys(map).filter((from) => map[from] !== from);
const foreign = [
...Object.keys(map).filter((from) => map[from] !== from),
...Object.values(fpm).flat(),
];
// names reports whether a file name carries arch as a whole segment, which is
// how both the rename and the leftover check recognise it.
function names(name, arch) {
return name.includes(`-${arch}.`) || name.includes(`-${arch}-`);
}
// plan records the rename of one artifact and of everything written beside it
// under its name — a .blockmap left behind points at a file that no longer
@@ -38,7 +47,9 @@ module.exports = async function canonicalArtifactNames(buildResult) {
const arch = built.get(path.resolve(file));
if (!arch) continue;
const to = canonical(arch);
if (to !== arch) plan(path.resolve(file), arch, to, renames);
const name = path.basename(file);
const from = spellings(arch).find((s) => names(name, s));
if (from && from !== to) plan(path.resolve(file), from, to, renames);
}
for (const [from, to] of renames) fs.renameSync(from, to);
@@ -53,7 +64,7 @@ module.exports = async function canonicalArtifactNames(buildResult) {
}
for (const file of out) {
const name = path.basename(file);
const stray = foreign.find((a) => name.includes(`-${a}.`) || name.includes(`-${a}-`));
const stray = foreign.find((a) => names(name, a));
if (stray) {
throw new Error(`${name} still names ${stray}; studio-manifest reads artifact names as GOARCH`);
}
+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 };
+35
View File
@@ -0,0 +1,35 @@
"use strict";
const PREF_KEY = "rx-hw-accel";
// A launch-only override for a window that will not draw: the environment
// variable or the flag, neither of which is written back to the preferences.
function shouldDisableGpu({ prefs, env, argv }) {
return accelerationOff(prefs) || env?.REASONIX_DISABLE_GPU === "1" || (argv ?? []).includes("--disable-gpu");
}
// Chromium reads this before the app is ready, so a saved "off" only reaches
// the launch after the one that saved it.
function accelerationOff(prefs) {
return prefs?.[PREF_KEY] === "off";
}
// launchedOff is what this launch applied (the saved choice or a launch-only
// override); savedOff is the saved choice alone. compositing is Electron's raw
// gpu_compositing string, which it documents as usable only after
// gpu-info-update and does not enumerate, so it is reported, never interpreted.
function graphicsReport(app, { launchedOff, savedOff }) {
let compositing = "";
try {
compositing = String(app.getGPUFeatureStatus().gpu_compositing ?? "");
} catch {
// Not answerable yet; the page shows only what was applied.
}
return { launchedOff, savedOff, compositing };
}
function graphicsHandler(app, fromWindow, launch) {
return (event) => (fromWindow(event) ? graphicsReport(app, launch) : null);
}
module.exports = { graphicsHandler, PREF_KEY, accelerationOff, shouldDisableGpu, graphicsReport };
+94
View File
@@ -0,0 +1,94 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const CRASH_DIR = "crash";
const MARKER = "unexpected-host-exit.json";
const CRASH_FILE = /^host-.*\.log$/;
const TAIL_BYTES = 8192;
const TAIL_LINES = 30;
const crashDir = (logsDir) => path.join(logsDir, CRASH_DIR);
const markerFile = (userData) => path.join(userData, MARKER);
// Only a file written during this launch can belong to this death.
function newestCrashFile(dir, startedAt) {
let best = null;
let names = [];
try {
names = fs.readdirSync(dir);
} catch {}
for (const name of names) {
if (!CRASH_FILE.test(name)) continue;
try {
const at = fs.statSync(path.join(dir, name)).mtimeMs;
if (at >= startedAt && (!best || at > best.at)) best = { name, at };
} catch {}
}
return best?.name ?? "";
}
function tailOf(file) {
try {
const fd = fs.openSync(file, "r");
try {
const size = fs.fstatSync(fd).size;
const len = Math.min(size, TAIL_BYTES);
const buf = Buffer.alloc(len);
fs.readSync(fd, buf, 0, len, size - len);
return buf.toString("utf8").split(/\r?\n/).filter((l) => l.trim() !== "").slice(-TAIL_LINES).join("\n");
} finally {
fs.closeSync(fd);
}
} catch {
return "";
}
}
// recordHostExit is for a kernel that died after its handshake without the
// shell asking it to: the cause goes to shell.log, and a marker waits for the
// next launch to tell the person.
function recordHostExit({ logs, userData, code, signal, startedAt, now = () => new Date() }) {
const dir = crashDir(logs.dir);
const crashFile = newestCrashFile(dir, startedAt);
logs.shell.line(`host: died code=${code} signal=${signal} crash=${crashFile || "(none)"}`);
const tail = crashFile ? tailOf(path.join(dir, crashFile)) : "";
if (tail) logs.shell.line(`host: last output of ${crashFile}:\n${tail}`);
try {
fs.writeFileSync(markerFile(userData), JSON.stringify({ at: now().toISOString(), code, signal, crashFile }), { mode: 0o600 });
} catch {}
}
function pendingHostExit(userData) {
try {
const seen = JSON.parse(fs.readFileSync(markerFile(userData), "utf8"));
return seen && typeof seen === "object" ? seen : null;
} catch {
return null;
}
}
function clearHostExit(userData) {
try {
fs.rmSync(markerFile(userData), { force: true });
} catch {}
}
function hostExitNotice(locale, exit, logsDir) {
const zh = String(locale).toLowerCase().startsWith("zh");
const how = exit?.signal ? `signal ${exit.signal}` : `code ${exit?.code}`;
if (zh) {
return {
message: "上次运行时内核异常退出了",
ok: "知道了",
detail: `内核进程在上次使用中途停止(${exit?.signal ? `信号 ${exit.signal}` : `退出码 ${exit?.code}`})。会话记录不受影响。反馈问题时请附上这个目录里的 shell.log、host.log 和 crash 文件夹:\n\n${logsDir}`,
};
}
return {
message: "The kernel exited unexpectedly last time",
ok: "OK",
detail: `The kernel process stopped partway through the last session (${how}). Your sessions are not affected. When reporting a problem, attach shell.log, host.log and the crash folder from:\n\n${logsDir}`,
};
}
module.exports = { crashDir, recordHostExit, pendingHostExit, clearHostExit, hostExitNotice };
+59 -40
View File
@@ -27,8 +27,12 @@ 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
// page drops the input the agent sends it: measured, its clicks never arrive and
@@ -62,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 = "";
@@ -120,9 +125,12 @@ async function launchKernel(args) {
// Before the handshake the launch itself fails, and boot's catch owns
// telling the person why; quitting here would race that dialog.
powerGuard?.close();
if (handshaken && code !== 0 && !quitting) app.quit();
if (handshaken && code !== 0 && !quitting) {
recordHostExit({ logs, userData: app.getPath("userData"), code, signal, startedAt: began });
app.quit();
}
},
onAct: handOver,
onAct: (act) => acts(act),
});
const current = kernel;
current.child.on("error", (err) => logs.shell.line(`host: spawn failed: ${err.message}`));
@@ -147,7 +155,7 @@ async function boot() {
// Only a packaged build has a version worth reporting -- app.getVersion()
// falls back to Electron's own, which named a Studio that never shipped and
// ranked it ahead of every published release.
const args = ["-page", pageDir];
const args = ["-page", pageDir, "-crash-dir", crashDir(logs.dir)];
if (computerHelper && existsSync(computerHelper)) args.push("-computer-helper", computerHelper);
if (app.isPackaged) {
args.push("-studio-version", app.getVersion());
@@ -198,6 +206,7 @@ async function boot() {
// the shell this one replaces, and a modal in front of a window that has not
// painted reads as the application having failed to start.
cleanUpLegacyInstalls();
announceHostExit();
}
// The agent's browser draws its pages as views in this window: the kernel
@@ -215,6 +224,18 @@ function hostAgentBrowser() {
});
}
// Told once: the marker stays until the person has seen the notice.
function announceHostExit() {
const userData = app.getPath("userData");
const exit = pendingHostExit(userData);
if (!exit) return;
const text = hostExitNotice(uiLang(), exit, logs.dir);
dialog
.showMessageBox(win, { type: "warning", message: text.message, detail: text.detail, buttons: [text.ok] })
.then(() => clearHostExit(userData))
.catch((err) => logs.shell.line(`host exit notice: ${err.message}`));
}
// The Wails install a dmg download leaves beside this one. Detached from boot:
// a launch must not wait on it, and a failure here is not a failed launch.
function cleanUpLegacyInstalls() {
@@ -292,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"}`],
},
},
});
}
@@ -346,6 +373,8 @@ nativeTheme.on("updated", () => {
if (win && !win.isDestroyed()) win.setBackgroundColor(ground());
});
ipcMain.handle("shell:graphics", (event) => graphicsHandler(app, fromWindow, gpuLaunch)(event));
ipcMain.handle("window:minimise", (event) => {
fromWindow(event)?.minimize();
});
@@ -434,6 +463,11 @@ app.setName("Reasonix Studio");
// already running owns those session files.
const identity = instanceID(hostBinary);
app.setPath("userData", profileFor(app.getPath("userData"), identity));
// Chromium decides on the GPU before the app is ready, so this is the last
// point a saved choice can still reach it.
const savedPrefs = loadPrefs(prefsFile(app.getPath("userData")));
const gpuLaunch = { launchedOff: shouldDisableGpu({ prefs: savedPrefs, env: process.env, argv: process.argv }), savedOff: accelerationOff(savedPrefs) };
if (gpuLaunch.launchedOff) app.disableHardwareAcceleration();
// A launch from a shortcut has no console, so this file is the only place a
// failure before the window can be read back from.
logs = openLogs(app.getPath("userData"));
@@ -492,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", () => {
+2
View File
@@ -25,6 +25,8 @@ contextBridge.exposeInMainWorld("reasonixHost", {
toggleMaximiseWindow: () => ipcRenderer.invoke("window:toggle-maximise"),
isWindowMaximised: () => ipcRenderer.invoke("window:is-maximised"),
closeWindow: () => ipcRenderer.invoke("window:close"),
// What the shell's graphics are running as this launch.
graphics: () => ipcRenderer.invoke("shell:graphics"),
openExternal: (url) => ipcRenderer.invoke("shell:open-external", String(url)),
// A pane and a path inside its workspace; main asks the kernel where that is.
revealPath: (base, rel) => ipcRenderer.invoke("shell:reveal", String(base), String(rel)),
+13 -4
View File
@@ -1,5 +1,4 @@
"use strict";
const { Menu, Tray, nativeImage, screen } = require("electron");
const { trayImage } = require("./trayimage.js");
// How often the fold is re-read. It is a projection the kernel recomputes on
@@ -10,18 +9,25 @@ const FOLD_INTERVAL_MS = 5000;
// installTray brings the icon up and reports whether there is one. A shell with
// no icon must never background its window: hiding it would leave a running
// session with nothing left to bring it back.
function installTray(host, { onOpen, onQuit }) {
function installTray(host, { onOpen, onQuit }, electron = require("electron")) {
const { Menu, Tray, nativeImage, screen } = electron;
const image = trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor);
if (image.isEmpty()) return null;
const icon = new Tray(image);
const state = { fold: null, prefs: null };
const paint = () => {
// The menu is rebuilt only when what it says changed. Linux hands the menu to
// the desktop's indicator over D-Bus, and replacing it every poll closes one
// the person has open or leaves the icon without one.
let painted = "";
const paint = (force = false) => {
// The sentence and the menu words come from the kernel with the numbers:
// the language is the desktop's setting, and one surface should not need a
// second catalogue to say what the first already said.
const line = state.fold?.line ?? "";
const words = state.fold?.labels ?? {};
const shown = JSON.stringify([line, words, !!state.prefs?.closeToTray]);
if (!force && shown === painted) return;
icon.setToolTip(line ? `Reasonix Studio — ${line}` : "Reasonix Studio");
icon.setContextMenu(
Menu.buildFromTemplate([
@@ -37,13 +43,16 @@ function installTray(host, { onOpen, onQuit }) {
// switch has one implementation whichever control asked.
const next = await host.setTrayPrefs(true, !state.prefs?.closeToTray);
if (next) state.prefs = next;
paint();
// The native checkbox has already flipped itself; redraw from state
// even when nothing changed, so a refused save puts it back.
paint(true);
},
},
{ type: "separator" },
{ label: words.quit ?? "Quit", click: onQuit },
]),
);
painted = shown;
};
const refit = () => icon.setImage(trayImage(nativeImage, screen.getPrimaryDisplay().scaleFactor));
+109
View File
@@ -0,0 +1,109 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// Where the window was when it last closed, so the next launch opens the same
// rectangle instead of the default. Only a rectangle some display can still
// show is reused: a monitor that has gone leaves the saved position pointing at
// nothing, and a frameless window has no title bar to drag back from there.
const SAVE_DELAY_MS = 400;
const GRAB = { width: 120, height: 40 };
const stateFile = (userData) => path.join(userData, "window-state.json");
const whole = (v) => Number.isFinite(v) && Number.isInteger(v);
function loadState(file) {
try {
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
if (!raw || typeof raw !== "object" || !whole(raw.width) || !whole(raw.height) || raw.width <= 0 || raw.height <= 0) return null;
const placed = whole(raw.x) && whole(raw.y);
return { ...(placed ? { x: raw.x, y: raw.y } : {}), width: raw.width, height: raw.height, maximized: raw.maximized === true };
} catch {
return null;
}
}
// Written beside and renamed over, so a crash mid-write leaves the last good copy.
function saveState(file, state) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const tmp = `${file}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(state));
fs.renameSync(tmp, file);
}
// The strip a person grabs the window by must lie inside some display's work area.
function grabbable(saved, areas) {
if (saved.x === undefined) return null;
return (
areas.find((a) => {
const across = Math.min(saved.x + saved.width, a.x + a.width) - Math.max(saved.x, a.x);
return across >= Math.min(GRAB.width, saved.width) && saved.y >= a.y && saved.y + GRAB.height <= a.y + a.height;
}) ?? null
);
}
/** placement answers the rectangle to open at. The size is cut to the display
* that shows the saved position, and the position is moved in until the whole
* window fits; a rectangle nothing shows keeps only its size, cut to the
* primary display, and the platform picks the place. The layout's minimum
* comes last. */
function placement({ saved, areas, primary, fallback, min }) {
if (!saved) return { ...fallback, maximized: false };
const home = grabbable(saved, areas);
const area = home ?? primary;
const width = Math.max(min.width, Math.min(saved.width, area.width));
const height = Math.max(min.height, Math.min(saved.height, area.height));
if (!home) return { width, height, maximized: saved.maximized };
const within = (v, lo, span, size) => Math.max(lo, Math.min(v, lo + span - size));
return { x: within(saved.x, area.x, area.width, width), y: within(saved.y, area.y, area.height, height), width, height, maximized: saved.maximized };
}
// A minimised or full-screen window reports the screen's rectangle as its normal
// bounds on some platforms, so what it says then is not what to come back to.
const settled = (win) => !win.isMinimized() && !win.isFullScreen();
const capture = (win) => ({ ...win.getNormalBounds(), maximized: win.isMaximized() });
/** track writes the window's rectangle shortly after it settles and once more as
* it closes. A minimised or full-screen window keeps its normal bounds. */
function track(win, file, { delay = SAVE_DELAY_MS, timer = setTimeout, untimer = clearTimeout } = {}) {
let pending = null;
const write = () => {
pending = null;
if (win.isDestroyed() || !settled(win)) return;
try {
saveState(file, capture(win));
} catch {
// A rectangle that did not save is one launch at the default size, not a reason to fail the window.
}
};
const later = () => {
if (pending) untimer(pending);
pending = timer(write, delay);
};
for (const event of ["resize", "move", "maximize", "unmaximize"]) win.on(event, later);
win.on("close", () => {
if (pending) untimer(pending);
write();
});
}
/** openWindow builds the window at its remembered rectangle and keeps that
* record current. It is hidden until the caller shows it, so a maximise has
* nothing to flicker against. */
function openWindow({ BrowserWindow, screen, file, fallback, min, options }) {
const { maximized, ...rect } = placement({
saved: loadState(file),
areas: screen.getAllDisplays().map((d) => d.workArea),
primary: screen.getPrimaryDisplay().workArea,
fallback,
min,
});
const win = new BrowserWindow({ ...rect, ...options });
if (maximized) win.maximize();
track(win, file);
return win;
}
module.exports = { stateFile, loadState, saveState, placement, track, openWindow, GRAB };
+328
View File
@@ -1721,3 +1721,331 @@ test("the window ground follows the stored theme and, for auto, the system", ()
assert.equal(groundFor({}, false), GROUND.light);
assert.equal(groundFor(undefined, true), GROUND.dark);
});
const { accelerationOff, shouldDisableGpu, graphicsReport, graphicsHandler, PREF_KEY: GPU_PREF } = require("../src/graphics.js");
test("hardware acceleration is off only when the saved preference says exactly that", () => {
assert.equal(GPU_PREF, "rx-hw-accel");
assert.equal(accelerationOff({}), false);
assert.equal(accelerationOff(undefined), false);
assert.equal(accelerationOff({ "rx-hw-accel": "on" }), false);
assert.equal(accelerationOff({ "rx-hw-accel": "" }), false);
assert.equal(accelerationOff({ "rx-hw-accel": "off" }), true);
});
test("the graphics report says what this launch applied and passes Electron's status through untouched", () => {
const app = (compositing) => ({ getGPUFeatureStatus: () => ({ gpu_compositing: compositing }) });
const auto = { launchedOff: false, savedOff: false };
for (const value of ["enabled", "enabled_force", "enabled_on", "disabled_software", "unavailable_off", ""]) {
assert.deepEqual(graphicsReport(app(value), auto), { ...auto, compositing: value });
}
assert.deepEqual(graphicsReport(app("disabled_software"), { launchedOff: true, savedOff: true }), { launchedOff: true, savedOff: true, compositing: "disabled_software" });
assert.deepEqual(graphicsReport(app("enabled"), { launchedOff: true, savedOff: false }), { launchedOff: true, savedOff: false, compositing: "enabled" });
assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => { throw new Error("not ready"); } }, auto), { ...auto, compositing: "" });
assert.deepEqual(graphicsReport({ getGPUFeatureStatus: () => ({}) }, auto), { ...auto, compositing: "" });
});
test("the GPU is disabled by the saved preference, the environment or the flag, and only then", () => {
const off = (over) => shouldDisableGpu({ prefs: {}, env: {}, argv: [], ...over });
assert.equal(off({}), false);
assert.equal(off({ prefs: { "rx-hw-accel": "off" } }), true);
assert.equal(off({ prefs: { "rx-hw-accel": "on" } }), false);
assert.equal(off({ prefs: undefined }), false);
assert.equal(off({ env: { REASONIX_DISABLE_GPU: "1" } }), true);
assert.equal(off({ env: { REASONIX_DISABLE_GPU: "0" } }), false);
assert.equal(off({ argv: ["studio", "--disable-gpu"] }), true);
assert.equal(off({ argv: ["--disable-gpu-sandbox"] }), false);
});
test("a prefs file that is missing or corrupt keeps hardware acceleration", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "gpu-"));
const file = path.join(dir, "window-prefs.json");
assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
fs.writeFileSync(file, "{ not json");
assert.equal(shouldDisableGpu({ prefs: loadPrefs(file), env: {}, argv: [] }), false);
});
test("the graphics report is refused to any sender that is not the Studio window", () => {
const app = { getGPUFeatureStatus: () => ({ gpu_compositing: "enabled" }) };
const win = { id: "studio" };
const handle = graphicsHandler(app, (event) => (event.sender === "studio" ? win : null), { launchedOff: true, savedOff: false });
assert.deepEqual(handle({ sender: "studio" }), { launchedOff: true, savedOff: false, compositing: "enabled" });
assert.equal(handle({ sender: "agent-browser-page" }), null);
});
test("a kernel that dies after its handshake leaves its exit and last words in shell.log and a marker for the next launch", () => {
const { openLogs } = require("../src/shelllog.js");
const { crashDir, recordHostExit, pendingHostExit, clearHostExit } = require("../src/hostexit.js");
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "hostexit-"));
const logs = openLogs(userData);
const dir = crashDir(logs.dir);
fs.mkdirSync(dir, { recursive: true });
const started = Date.now() - 1000;
const stale = path.join(dir, "host-20200101T000000Z-2.0.0-1.log");
fs.writeFileSync(stale, "fatal error: an earlier run\n");
fs.utimesSync(stale, new Date(2020, 0, 1), new Date(2020, 0, 1));
const body = Array.from({ length: 80 }, (_, i) => `frame ${i}`).join("\n");
fs.writeFileSync(path.join(dir, "host-20261010T000000Z-2.31.0-7.log"), `fatal error: out of memory\n${body}\n`);
assert.equal(pendingHostExit(userData), null, "no marker before any exit");
recordHostExit({ logs, userData, code: 2, signal: null, startedAt: started });
const shell = fs.readFileSync(logs.shell.file, "utf8");
assert.match(shell, /host: died code=2 signal=null crash=host-20261010T000000Z-2\.31\.0-7\.log/);
assert.match(shell, /frame 79/, "the tail of the crash file must reach shell.log");
assert.doesNotMatch(shell, /frame 10\b/, "only the tail is quoted");
assert.doesNotMatch(shell, /an earlier run/, "a crash file from before this launch is not this death");
const pending = pendingHostExit(userData);
assert.equal(pending.code, 2);
assert.equal(pending.crashFile, "host-20261010T000000Z-2.31.0-7.log");
clearHostExit(userData);
assert.equal(pendingHostExit(userData), null, "acknowledging clears the marker");
});
test("a kernel killed by a signal with no crash file is still recorded", () => {
const { openLogs } = require("../src/shelllog.js");
const { recordHostExit, pendingHostExit } = require("../src/hostexit.js");
const userData = fs.mkdtempSync(path.join(os.tmpdir(), "hostexit-"));
const logs = openLogs(userData);
recordHostExit({ logs, userData, code: null, signal: "SIGKILL", startedAt: Date.now() });
assert.match(fs.readFileSync(logs.shell.file, "utf8"), /host: died code=null signal=SIGKILL crash=\(none\)/);
assert.equal(pendingHostExit(userData).signal, "SIGKILL");
});
test("the notice names where the logs are in both languages and shows how it ended", () => {
const { hostExitNotice } = require("../src/hostexit.js");
const en = hostExitNotice("en-US", { code: 2, signal: null }, "/logs");
const zh = hostExitNotice("zh-CN", { code: 2, signal: null }, "/logs");
assert.notEqual(en.message, zh.message);
for (const text of [en, zh]) assert.match(text.detail, /\/logs/);
assert.match(zh.message, /内核/);
assert.match(en.detail, /code 2/);
assert.match(zh.detail, /2/);
});
test("packaging: an rpm fpm spelled x86_64 leaves the build as the release arch", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
const deb = path.join(dir, "ReasonixStudio-linux-amd64.deb");
const rpm = path.join(dir, "ReasonixStudio-linux-x86_64.rpm");
fs.writeFileSync(deb, "d");
fs.writeFileSync(rpm, "r");
arch.record(deb, "x64");
arch.record(rpm, "x64");
const out = await hook({ artifactPaths: [deb, rpm] });
assert.deepEqual(out.map((f) => path.basename(f)), ["ReasonixStudio-linux-amd64.deb", "ReasonixStudio-linux-amd64.rpm"]);
assert.ok(fs.existsSync(path.join(dir, "ReasonixStudio-linux-amd64.rpm")));
assert.ok(!fs.existsSync(rpm));
});
test("packaging: a name still carrying an fpm spelling is refused", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "rpm-name-"));
const stray = path.join(dir, "ReasonixStudio-linux-aarch64.rpm");
fs.writeFileSync(stray, "r");
arch.record(stray, "x64");
await assert.rejects(hook({ artifactPaths: [stray] }), /aarch64/);
});
test("packaging: a Windows installer named for x64 is renamed to amd64", async () => {
const arch = require("../packaging/arch.js");
const hook = require("../packaging/canonical-artifact-names.js");
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "win-name-"));
const installer = path.join(dir, "ReasonixStudio-windows-x64-installer.exe");
const zip = path.join(dir, "ReasonixStudio-windows-x64.zip");
for (const f of [installer, zip]) {
fs.writeFileSync(f, "x");
arch.record(f, "x64");
}
const out = await hook({ artifactPaths: [installer, zip] });
assert.deepEqual(
out.map((f) => path.basename(f)),
["ReasonixStudio-windows-amd64-installer.exe", "ReasonixStudio-windows-amd64.zip"],
);
});
const { stateFile, loadState, saveState, placement, track } = require("../src/windowstate.js");
const { EventEmitter } = require("node:events");
const AREA = { x: 0, y: 0, width: 1920, height: 1040 };
const SECOND = { x: 1920, y: 0, width: 1280, height: 1000 };
const MIN = { width: 760, height: 480 };
const FALLBACK = { width: 1440, height: 900 };
const open = (saved, areas = [AREA, SECOND]) => placement({ saved, areas, primary: areas[0], fallback: FALLBACK, min: MIN });
test("window state: only a whole rectangle is read back, and it survives a round trip", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
assert.equal(loadState(file), null);
fs.writeFileSync(file, "{not json");
assert.equal(loadState(file), null);
for (const bad of [{ width: 0, height: 500 }, { width: "900", height: 500 }, { width: 900.5, height: 500 }, [], null]) {
fs.writeFileSync(file, JSON.stringify(bad));
assert.equal(loadState(file), null, JSON.stringify(bad));
}
saveState(file, { x: 40, y: 60, width: 1000, height: 700, maximized: true });
assert.deepEqual(loadState(file), { x: 40, y: 60, width: 1000, height: 700, maximized: true });
fs.writeFileSync(file, JSON.stringify({ width: 1000, height: 700 }));
assert.deepEqual(loadState(file), { width: 1000, height: 700, maximized: false });
});
test("window state: a first launch opens at the default size and a saved rectangle is reopened as it was", () => {
assert.deepEqual(open(null), { ...FALLBACK, maximized: false });
const saved = { x: 300, y: 120, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(saved), saved);
assert.deepEqual(open({ ...saved, x: 2100, y: 100 }), { ...saved, x: 2100, y: 100 });
});
test("window state: a rectangle no display shows keeps its size and loses its place", () => {
const gone = { x: 2400, y: 100, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(gone, [AREA]), { width: 1100, height: 760, maximized: false });
const above = { x: 300, y: -500, width: 1100, height: 760, maximized: false };
assert.deepEqual(open(above, [AREA]), { width: 1100, height: 760, maximized: false });
const huge = { x: 5000, y: 0, width: 4000, height: 3000, maximized: true };
assert.deepEqual(open(huge, [AREA]), { width: 1920, height: 1040, maximized: true });
});
test("window state: a saved size is raised to the layout's floor", () => {
const small = open({ x: 10, y: 10, width: 300, height: 200, maximized: false });
assert.deepEqual([small.width, small.height], [760, 480]);
});
function fakeWindow(normal) {
const win = new EventEmitter();
Object.assign(win, { maximized: false, destroyed: false, normal });
win.getNormalBounds = () => win.normal;
win.isMaximized = () => win.maximized;
win.isDestroyed = () => win.destroyed;
win.minimized = false;
win.fullscreen = false;
win.isMinimized = () => win.minimized;
win.isFullScreen = () => win.fullscreen;
return win;
}
test("window state: moves are written once they settle, a close is written at once, and a maximised window keeps its normal bounds", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
const timers = [];
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
track(win, file, { timer: (fn) => timers.push(fn) - 1, untimer: (id) => (timers[id] = () => {}) });
win.emit("resize");
win.emit("move");
win.emit("resize");
assert.equal(loadState(file), null);
timers.at(-1)();
assert.deepEqual(loadState(file), { x: 10, y: 20, width: 900, height: 600, maximized: false });
win.normal = { x: 50, y: 60, width: 1000, height: 650 };
win.maximized = true;
win.emit("close");
assert.deepEqual(loadState(file), { x: 50, y: 60, width: 1000, height: 650, maximized: true });
win.destroyed = true;
win.normal = { x: 1, y: 1, width: 5, height: 5 };
win.emit("move");
timers.at(-1)();
assert.equal(loadState(file).width, 1000);
});
test("window state: a minimised or full-screen close keeps the last settled record", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "winstate-"));
const file = stateFile(dir);
const win = fakeWindow({ x: 10, y: 20, width: 900, height: 600 });
track(win, file, { timer: () => 0, untimer: () => {} });
win.emit("close");
const kept = loadState(file);
win.normal = { x: 0, y: 0, width: 1920, height: 1040 };
win.minimized = true;
win.emit("close");
assert.deepEqual(loadState(file), kept);
win.minimized = false;
win.fullscreen = true;
win.emit("close");
assert.deepEqual(loadState(file), kept);
});
test("window state: a saved size is cut to the display it opens on and the window is moved in", () => {
const big = { x: 1700, y: 600, width: 1400, height: 900, maximized: false };
const out = open(big, [AREA]);
assert.deepEqual([out.width, out.height], [1400, 900].map((v, i) => Math.min(v, [1920, 1040][i])));
assert.ok(out.x + out.width <= 1920 && out.y + out.height <= 1040, JSON.stringify(out));
const small = { x: 100, y: 100, width: 1800, height: 1000, maximized: false };
const cut = open(small, [{ x: 0, y: 0, width: 1366, height: 728 }]);
assert.deepEqual(cut, { x: 0, y: 0, width: 1366, height: 728, maximized: false });
});
test("kernel acts: show brings the window forward, relaunch arms the restart, quit ends, anything else is ignored", () => {
const { actHandler } = require("../src/acts.js");
const calls = [];
const handle = actHandler({
app: { relaunch: () => calls.push("relaunch"), quit: () => calls.push("quit") },
showWindow: () => calls.push("show"),
});
for (const act of ["show", "relaunch", "quit", "unknown"]) handle(act);
assert.deepEqual(calls, ["show", "relaunch", "quit"]);
});
test("tray: the menu is replaced only when what it says changes, so an open menu is not torn down by the poll", async () => {
const { installTray } = require("../src/tray.js");
const menus = [];
let tips = 0;
class FakeTray {
on() {}
setImage() {}
setToolTip() { tips++; }
setContextMenu(menu) { menus.push(menu); }
destroy() {}
}
const electron = {
Menu: { buildFromTemplate: (template) => ({ template }) },
Tray: FakeTray,
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
};
let fold = { line: "1 running", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => ({ closeToTray: true }) };
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
await tray.refresh();
await tray.refresh();
await tray.refresh();
assert.equal(menus.length, 1, "three identical reads rebuilt the menu");
assert.equal(tips, 1);
fold = { ...fold, line: "2 running" };
await tray.refresh();
assert.equal(menus.length, 2);
assert.equal(menus[1].template.find((item) => item.enabled === false).label, "2 running");
tray.close();
});
test("tray: a refused close-to-tray save redraws the checkbox, and a menu that failed to draw is tried again", async () => {
const { installTray } = require("../src/tray.js");
const menus = [];
let failNext = false;
class FakeTray {
on() {}
setImage() {}
setToolTip() {}
setContextMenu(menu) { if (failNext) { failNext = false; throw new Error("indicator busy"); } menus.push(menu); }
destroy() {}
}
const electron = {
Menu: { buildFromTemplate: (template) => ({ template }) },
Tray: FakeTray,
nativeImage: { createFromPath: () => ({ isEmpty: () => false }), createFromBuffer: () => ({ isEmpty: () => false }) },
screen: { getPrimaryDisplay: () => ({ scaleFactor: 1 }), on() {}, removeListener() {} },
};
const fold = { line: "idle", labels: { open: "Open", quit: "Quit", closeToTray: "Keep running" } };
const host = { trayPrefs: async () => ({ closeToTray: false }), trayState: async () => fold, setTrayPrefs: async () => null };
const tray = installTray(host, { onOpen() {}, onQuit() {} }, electron);
failNext = true;
await assert.rejects(tray.refresh());
await tray.refresh();
assert.equal(menus.length, 1, "a menu whose draw threw was never retried");
const toggle = menus[0].template.find((item) => item.type === "checkbox");
await toggle.click();
assert.equal(menus.length, 2, "a refused save left the flipped checkbox on screen");
assert.equal(menus[1].template.find((item) => item.type === "checkbox").checked, false);
tray.close();
});
+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"
}
}
+10
View File
@@ -46,6 +46,7 @@ node perf/pick.mjs # 补全菜单:/ 和 @ 的选中态,深浅两色下
node perf/models.mjs # 连接面板:网关报出一百多个模型时,这一屏还能不能用
node perf/side.mjs # 右栏:端点自己写的一句话有多长,都不该改变栏宽
node perf/queue.mjs # 待送达:正文读不回来时,「改」不许开出一个空编辑器
node perf/reask.mjs # 改写框:没有换行的长段落,框要长到让人读得到全文,宽屏和手机宽度各一遍
node perf/onboarding.mjs # First-run setup: zoom-aware card bounds, resize, scrolling, focus and completion
node perf/running.mjs # 执行过程:在跑的步骤自己在动、收起后由标题接着动,步骤行没有空栏
node perf/lang.mjs # 双语验证:英文启动、界面译文到位、切回中文
@@ -59,12 +60,21 @@ node perf/contrast.mjs # 对比度的守卫:每一段正文对它真正落
node perf/concurrent.mjs # 多会话并发的守卫:9 个会话同时流式,后台窗格不许以流式速率重绘,帧率不许掉(手跑,不进自动检查:帧率比值阈值在 2 核机器上随硬件抖动,CI 上实测 0.68/0.69 < 0.7)
node perf/wheel.mjs # 滚轮轨迹(只报告、不判红):向上滚时 scrollTop 不倒走、单步不超滚轮、scrollHeight 不缩水;WHEEL_STRICT=1 判红
node perf/idle.mjs # 闲置的守卫:看不见的东西不许动,闲着的窗口不许烧 CPU
node perf/effects.mjs # Effects guard: a sky pack idles quietly, a hidden window draws no sky, Reduced removes blur, sky and grain
node perf/shipped.mjs # 产物的守卫:dist 里的东西必须比源码新(构建可以成功而编的是别的树)
node perf/reason.mjs # 内核拒绝的双语落地:同一个码,中英各说各的
node perf/budget.mjs # 预算的守卫:输入框上下堆满东西时,转录还剩多少
node perf/find.mjs # 对话内查找:快捷键穿过输入框、跳到没挂载的那条、同一行里的第 n 处落在眼前
node perf/focus.mjs # 专注的守卫:拿走外围之后,退出时世界是否原样
node perf/tooltip.mjs # 控件提示的守卫:文字不溢出提示框、不在半透明祖先之下、对比度达 AA(深浅×中英×宽窄)
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/`(它要读固件源码来决定
@@ -0,0 +1,66 @@
import { chromium } from "playwright";
import { mkdir } from "node:fs/promises";
const base = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const shots = process.env.BRANCH_TOOLTIP_SCREENSHOTS;
if (shots) await mkdir(shots, { recursive: true });
const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE });
const fails = [];
try {
for (const language of ["en", "zh"]) {
for (const zoom of [1]) {
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, reducedMotion: "reduce" });
page.on("pageerror", (error) => fails.push(error.message));
await page.goto(`${base}?pref=${language}&turns=2&git=none&zoom=${zoom}`, { waitUntil: "networkidle" });
const chip = page.locator(".studio-branch[data-norepo]");
await chip.waitFor();
await page.evaluate(() => document.fonts.ready);
const label = language === "en" ? "No Git repository" : "非 Git 仓库";
const accessible = await chip.and(page.getByRole("img", { name: label, exact: true })).count() === 1;
const hiddenLabel = await chip.locator(".lb").evaluate((element) => getComputedStyle(element).display === "none");
const identity = `${language}/390px/zoom=${zoom}/accessible-name`;
console.log(`${accessible && hiddenLabel ? "ok" : "FAIL"} ${identity}`, { accessible, hiddenLabel });
if (!accessible || !hiddenLabel) fails.push(identity);
for (const interaction of ["hover", "focus"]) {
if (interaction === "hover") await chip.hover();
else {
await page.mouse.move(0, 0);
await chip.focus();
await page.keyboard.press("Shift+Tab");
await page.keyboard.press("Tab");
if (!await chip.evaluate((element) => element === document.activeElement)) {
fails.push(`${language}/390px/keyboard-focus`);
}
}
await page.waitForTimeout(160);
const result = await page.locator(".studio-branch-card").evaluate((card) => {
const box = card.getBoundingClientRect();
const pane = card.closest(".pane").getBoundingClientRect();
const ranges = [...card.children].flatMap((child) => {
const range = document.createRange();
range.selectNodeContents(child);
return [...range.getClientRects()];
});
return {
left: box.left, right: box.right, paneLeft: pane.left, paneRight: pane.right,
viewport: innerWidth, visible: getComputedStyle(card).visibility === "visible",
wrapped: getComputedStyle(card).whiteSpace === "normal",
textFits: ranges.every((range) => range.left >= box.left && range.right <= box.right + 0.5),
scrollFits: card.scrollWidth <= card.clientWidth,
};
});
const name = `${language}/390px/zoom=${zoom}/${interaction}`;
const okay = result.visible && result.wrapped && result.textFits && result.scrollFits &&
result.left >= Math.max(0, result.paneLeft) && result.right <= Math.min(result.viewport, result.paneRight);
console.log(`${okay ? "ok" : "FAIL"} ${name}`, result);
if (!okay) fails.push(name);
if (shots) await page.screenshot({ path: `${shots}/${language}-${zoom}-${interaction}.png` });
}
await page.close();
}
}
} finally {
await browser.close();
}
console.log(JSON.stringify({ guard: "branch-tooltip", failures: fails }));
if (fails.length) process.exit(1);
+36 -1
View File
@@ -58,6 +58,13 @@ for (const { width, height } of [{ width: 1440, height: 900 }, { width: 640, hei
primary: box(".ask-foot .btn"),
secondary: box(".ask-foot .dismiss"),
foot: box(".ask-foot"),
free: (() => {
const el = document.querySelector(".ask-pane[data-on] .other-wrap input");
const r = el?.getBoundingClientRect();
return r && r.width > 0 && r.height > 0 ? { top: r.top, bottom: r.bottom, left: r.left, right: r.right } : null;
})(),
view: { w: innerWidth, h: innerHeight },
runLabel: document.querySelector(".studio-runlabel")?.textContent ?? "",
approvalRows: [...new Set(actions.map((r) => Math.round(r.y)))].length,
approvalMainWidth: actions[0]?.width ?? 0,
approvalWidth: apv?.getBoundingClientRect().width ?? 0,
@@ -66,6 +73,9 @@ for (const { width, height } of [{ width: 1440, height: 900 }, { width: 640, hei
check(`${width}px:正文不横向溢出`, layout.proseOverflow <= 1, `${Math.round(layout.proseOverflow)}px`);
check(`${width}px:提问卡不撑宽`, layout.askOverflow <= 1, `${Math.round(layout.askOverflow)}px`);
check(`${width}px:转录不横向溢出`, layout.flowOverflow <= 1, `${Math.round(layout.flowOverflow)}px`);
check(`${width}px:只有选项的提问卡上,自由作答入口不点任何东西就可见`, !!layout.free);
check(`${width}px:作答入口完整落在可视区内`, !!layout.free && layout.free.top >= 0 && layout.free.bottom <= layout.view.h && layout.free.left >= 0 && layout.free.right <= layout.view.w);
check(`${width}px:状态行写明在等你回答`, layout.runLabel.includes("等待你回答"), layout.runLabel);
check(`${width}px:问题标签可横向到达`, layout.tabsOverflow === "auto");
if (width === 420) {
check("420px:提问主次动作分行", layout.primary.bottom <= layout.secondary.top + 1);
@@ -155,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 = {};
@@ -187,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");
+139
View File
@@ -0,0 +1,139 @@
// Effects guard: decoration must not keep the GPU busy. A window with a sky
// pack drew a blurred cloud bank under six frosted panels, so every frame
// re-blurred 41% of the screen; the sky never stopped for a window nobody was
// looking at. Idle has to be idle, and "Reduced" has to remove those layers.
import { chromium } from "playwright";
const BASE = 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();
async function open(pack, { effects = "", reducedMotion = "no-preference" } = {}) {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion, colorScheme: "dark" });
await ctx.addInitScript((v) => {
if (v) localStorage.setItem("rx-effects", v);
window.__sky = 0;
const clear = CanvasRenderingContext2D.prototype.clearRect;
CanvasRenderingContext2D.prototype.clearRect = function (...a) {
if (this.canvas.width === 400 && this.canvas.height === 225) window.__sky++;
return clear.apply(this, a);
};
}, effects);
const page = await ctx.newPage();
await page.goto(`${BASE.replace(/\?.*/, "")}?pack=${pack}`, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(1200);
return { ctx, page };
}
const skyFrames = async (page, ms) => {
const a = await page.evaluate(() => window.__sky);
await page.waitForTimeout(ms);
return (await page.evaluate(() => window.__sky)) - a;
};
// Surfaces that blur what is behind them, pseudo-elements included: the
// transcript's backing layer is one, and it is the largest.
const blurred = (page) =>
page.evaluate(() => {
const out = [];
for (const el of document.querySelectorAll("*")) {
for (const pseudo of [null, "::before", "::after"]) {
const cs = getComputedStyle(el, pseudo);
const b = cs.backdropFilter || cs.webkitBackdropFilter;
if (!b || b === "none") continue;
if (pseudo && cs.content === "none") continue;
if (!pseudo && (cs.display === "none" || cs.visibility === "hidden")) continue;
const r = el.getBoundingClientRect();
if (r.width * r.height === 0) continue;
out.push(`${el.tagName.toLowerCase()}.${String(el.className).slice(0, 18)}${pseudo ?? ""}`);
}
}
return out;
});
const timeLoops = (page) =>
page.evaluate(() =>
document.getAnimations({ subtree: true })
.filter((a) => a.playState === "running" && a.effect?.getTiming().iterations === Infinity && a.timeline?.constructor.name === "DocumentTimeline")
.map((a) => `${a.animationName ?? "?"} @ ${a.effect.target?.className?.toString?.().slice(0, 20)}`),
);
{
const { ctx, page } = await open("sky");
check("sky pack: the sky is mounted", (await page.locator(".sky").count()) === 1);
const loops = await timeLoops(page);
check("sky pack at idle: no CSS loop runs behind the window", loops.length === 0, loops.join("; ") || "0");
const n = await skyFrames(page, 3000);
check("sky pack at idle: the canvas redraws at most 20 times a second", n <= 60 && n > 0, `${n} redraws in 3 s`);
const bd = await blurred(page);
check("sky pack: at most two surfaces blur their backdrop", bd.length <= 2, bd.join(", "));
await page.evaluate(() => {
Object.defineProperty(document, "hidden", { configurable: true, get: () => true });
document.dispatchEvent(new Event("visibilitychange"));
});
await page.waitForTimeout(300);
check("a hidden window draws no sky", (await skyFrames(page, 1500)) <= 1);
await page.evaluate(() => {
delete document.hidden;
document.dispatchEvent(new Event("visibilitychange"));
});
await page.waitForTimeout(800);
await page.evaluate(() => {
document.hasFocus = () => false;
window.dispatchEvent(new Event("blur"));
});
await page.waitForTimeout(300);
check("a window without the keyboard draws no sky", (await skyFrames(page, 1500)) <= 1);
await page.evaluate(() => {
document.hasFocus = () => true;
window.dispatchEvent(new Event("focus"));
});
await page.waitForTimeout(800);
check("it moves again once shown", (await skyFrames(page, 1500)) > 0);
await ctx.close();
}
{
const { ctx, page } = await open("sky", { reducedMotion: "reduce" });
check("prefers-reduced-motion: the sky holds still", (await skyFrames(page, 2000)) === 0);
check("prefers-reduced-motion: no CSS loop at idle", (await timeLoops(page)).length === 0);
await ctx.close();
}
{
const { ctx, page } = await open("photo", { effects: "reduced" });
const bd = await blurred(page);
check("Reduced: nothing blurs its backdrop", bd.length === 0, bd.join(", "));
const grain = await page.evaluate(() => getComputedStyle(document.querySelector(".app"), "::after").display);
check("Reduced: the grain layer is gone", grain === "none", grain);
check("Reduced: data-effects is on the root", (await page.evaluate(() => document.documentElement.dataset.effects)) === "reduced");
await ctx.close();
}
{
const { ctx, page } = await open("sky");
await page.keyboard.press("Meta+Comma");
await page.waitForTimeout(500);
await page.evaluate(() => document.getElementById("prefs-appearance")?.click());
await page.waitForSelector('[data-action="appearance.effects"]', { state: "attached", timeout: 5000 });
const sky0 = await page.locator(".sky").count();
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="reduced"]')?.click());
await page.waitForTimeout(500);
check("choosing Reduced takes the sky away at once", sky0 === 1 && (await page.locator(".sky").count()) === 0);
check("choosing Reduced is remembered", (await page.evaluate(() => localStorage.getItem("rx-effects"))) === "reduced");
check("Reduced leaves no blurred surface", (await blurred(page)).length === 0);
await page.evaluate(() => document.querySelector('[data-action="appearance.effects"][data-value="full"]')?.click());
await page.waitForTimeout(500);
check("choosing Full brings the sky back", (await page.locator(".sky").count()) === 1);
await ctx.close();
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed:\n- ${fails.join("\n- ")}` : "\nall passed");
process.exit(fails.length ? 1 : 0);
+119
View File
@@ -0,0 +1,119 @@
// A column's drag handle must not sit on top of the scrollbar beside it: a press on the thumb has to scroll, and a
// press on the handle has to resize. Hit-testing is a layout question, so it is asked of a real browser.
import { chromium } from "playwright";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.GUTTER_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
// The scrollbar is 10px wide against the scroller's right edge, its thumb inset 3px on each side.
const THUMB = 5;
const hit = (page, x, y) =>
page.evaluate(([x, y]) => {
const el = document.elementFromPoint(x, y);
const g = el?.closest(".gutter");
return { gutter: g ? g.className : null, tag: el ? el.className || el.tagName : null };
}, [x, y]);
// Every row of the scroller's right edge, not one sample: a handle that only covers the top or the middle is still a
// handle on the thumb whenever the thumb is there.
const sweep = (page, right, top, height) =>
page.evaluate(([right, top, height, inset]) => {
const hits = [];
for (let y = top + 2; y < top + height - 2; y += 12) {
for (const dx of inset) {
const g = document.elementFromPoint(right - dx, y)?.closest(".gutter");
if (g) hits.push(`${g.className.split(" ").at(-1)}@${Math.round(y)}`);
}
}
return hits;
}, [right, top, height, [4, 5, 6]]);
const flowBox = (page) =>
page.evaluate(() => {
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
return { right: r.right, top: r.top, height: r.height };
});
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const width of [1280, 1440]) {
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
page.setDefaultTimeout(8000);
page.on("pageerror", (e) => fails.push("page error: " + e.message));
await page.goto(`${BASE}?pref=en&turns=30`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
const tag = `${scheme} ${width}px`;
const flow = await page.evaluate(() => {
const r = document.querySelector('[data-pane="flow"]').getBoundingClientRect();
return { right: r.right, top: r.top, height: r.height };
});
const dockShut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
check(`${tag}: the workbench starts shut`, dockShut === true);
const covered = await sweep(page, flow.right, flow.top, flow.height);
check(`${tag}: no row of the transcript's scrollbar is under the workbench handle`, covered.length === 0, covered.slice(0, 4).join(" "));
await page.mouse.move(flow.right - 5, flow.top + 8);
await page.mouse.down();
await page.mouse.move(flow.right - 5, flow.top + 160, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(250);
check(`${tag}: pressing the scrollbar's top and dragging does not open the workbench`, (await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"))) === true);
const grip = await page.locator(".gutter-r .grip").boundingBox();
const pane = await page.evaluate(() => {
const r = document.querySelector(".pane").getBoundingClientRect();
return { top: r.top, height: r.height };
});
const mid = pane.top + pane.height / 2;
check(`${tag}: the shut handle sits mid-height`, Math.abs(grip.y + grip.height / 2 - mid) <= pane.height * 0.05, `${Math.round(grip.y + grip.height / 2)} against ${Math.round(mid)}`);
const onGrip = await hit(page, grip.x + grip.width / 2, grip.y + grip.height / 2);
check(`${tag}: the shut workbench still has a handle to press`, onGrip.gutter?.includes("gutter-r") === true, String(onGrip.gutter));
const rail = await page.evaluate(() => {
const el = document.querySelector(".rail .scroll");
const r = el?.getBoundingClientRect();
return r && r.width > 0 ? { right: r.right, top: r.top, height: r.height } : null;
});
if (rail) {
const edge = await hit(page, rail.right - THUMB, rail.top + rail.height * 0.25);
check(`${tag}: the workspace column's scrollbar is not under its handle`, edge.gutter === null, String(edge.gutter));
const seam = await hit(page, rail.right + 3, rail.top + rail.height * 0.75);
check(`${tag}: the workspace column's seam still resizes`, seam.gutter?.includes("gutter-l") === true, String(seam.gutter));
}
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-shut-${scheme}-${width}.png` });
await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2);
await page.mouse.down();
await page.mouse.move(grip.x - 200, grip.y + grip.height / 2, { steps: 8 });
await page.mouse.up();
await page.waitForTimeout(300);
const shut = await page.evaluate(() => document.querySelector(".gutter-r")?.hasAttribute("data-shut"));
check(`${tag}: dragging the handle opens the workbench`, shut === false);
const now = await flowBox(page);
const openCovered = await sweep(page, now.right, now.top, now.height);
check(`${tag}: nor is it once the workbench is open`, openCovered.length === 0, openCovered.slice(0, 4).join(" "));
const seamHit = await hit(page, now.right + 2, now.top + now.height * 0.5);
check(`${tag}: the open seam still resizes`, seamHit.gutter?.includes("gutter-r") === true, String(seamHit.gutter));
const wide = () => page.evaluate(() => document.querySelector('.scroll[data-pane="browser"]').getBoundingClientRect().width);
const before = await wide();
await page.mouse.move(now.right - 5, now.top + 8);
await page.mouse.down();
await page.mouse.move(now.right - 5, now.top + 160, { steps: 6 });
await page.mouse.up();
await page.waitForTimeout(250);
check(`${tag}: pressing the scrollbar and dragging leaves the open workbench's width alone`, Math.abs((await wide()) - before) < 1);
if (SHOTS) await page.screenshot({ path: `${SHOTS}/${process.env.GUTTER_TAG ?? "shot"}-${scheme}-${width}.png` });
await page.close();
}
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
console.log("\nall ok");
+57
View File
@@ -3,13 +3,20 @@ 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";
import { MockPort } from "../src/port/mock";
import { fromHistory } from "../src/state/session";
import type { AgentPort, Appearance, HistoryMessage } from "../src/port/port";
import type { ThemePack } from "../src/port/look";
import type { RuntimeView, TreeWorkspace } from "../src/port/hub";
import type { WireEvent } from "../src/port/wire";
@@ -23,9 +30,38 @@ let peakInFlight = 0;
// A port whose event stream the driver owns outright: the fixture's scripted
// beats never reach the UI, so a measurement times exactly the frames it fed.
// ?pack=sky opens on a pack with the live sky; ?pack=photo on one with a
// picture. Both are the states where the shell goes translucent over a backdrop.
const PACK = new URLSearchParams(location.search).get("pack");
function benchPack(): ThemePack | null {
const tokens = {
light: { bg: "#F2F6F8", bgSoft: "#F9FCFD", panel: "#FFFFFF", border: "#CBD9E0", fg: "#12191C", fgDim: "#4E5D65", accent: "#0E6E82" },
dark: { bg: "#080D10", bgSoft: "#0C1316", panel: "#131C21", border: "#23333A", fg: "#E4EEF2", fgDim: "#8298A2", accent: "#4FB6CE" },
};
if (PACK === "sky") {
return {
id: "bench-sky", name: "Bench sky", active: true, tokens,
sky: { ray: "rgba(255,216,142,.55)", cloud: "255,240,212", cloudLit: "242,206,140", rayAlpha: 0.85, cloudAlpha: 0.4 },
};
}
if (PACK === "photo") {
return {
id: "bench-photo", name: "Bench photo", active: true, tokens,
background: { image: true, focusX: 0.5, focusY: 0.5, homeOpacity: 0.9, taskOpacity: 0.5, overlayStrength: 0.4 },
};
}
return null;
}
class BenchPort extends MockPort {
private readonly subs = new Set<(e: WireEvent) => void>();
async themes(): Promise<ThemePack[]> {
const pack = benchPack();
return pack ? [pack] : super.themes();
}
async saveAppearance(look: Appearance) {
saves.push(look);
inFlight++;
@@ -48,6 +84,15 @@ 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;
}
async appearance() {
let look = await super.appearance();
if (query.has("zoom")) look = { ...look, zoom: Number(query.get("zoom")) };
@@ -100,6 +145,12 @@ class BenchPort extends MockPort {
return { ...q, items, capacity: { ...q.capacity, items: QUEUE, maxItems: 64 } };
}
// ?queuebody= makes a seeded line readable back, as the kernel's is.
async readQueued(itemId: string): Promise<string> {
if (!QUEUE_BODY) return super.readQueued(itemId);
return "请把这个模块里所有的错误处理统一改成带类型的错误,并且保证调用方可以用 errors.Is 区分每一种失败,同时补上对应的单元测试,再检查文档里提到旧行为的地方是否需要更新。".repeat(3);
}
// ?statusms= stands in for what the kernel's /status really costs when the
// provider declares a wallet endpoint: that read goes out to the network.
// ?jobs= seeds that many background jobs; stopping one is kept here because
@@ -157,6 +208,7 @@ const TURNS = Number(query.get("turns") ?? 0);
const STATUS_MS = Number(query.get("statusms") ?? 0);
// ?queue= is how many lines are already waiting when the window opens.
const QUEUE = Number(query.get("queue") ?? 0);
const QUEUE_BODY = query.has("queuebody");
const JOBS = Number(query.get("jobs") ?? 0);
// ?treems= holds every tree read back until that long after load, so the
// window can be seen before the kernel has named a single folder.
@@ -167,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");
+96
View File
@@ -0,0 +1,96 @@
// 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";
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.NAVHINT_SHOTS;
const fails = [];
const check = (name, ok, detail = "") => {
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
if (!ok) fails.push(name);
};
const measure = (page, index) =>
page.evaluate((index) => {
const tip = document.querySelectorAll(".nav .navbtn")[index]?.querySelector(".navhint");
if (!tip) return null;
const box = tip.getBoundingClientRect();
let opacity = 1;
let clipped = null;
const view = { left: 0, top: 0, right: innerWidth, bottom: innerHeight };
for (let el = tip; el; el = el.parentElement) {
const cs = getComputedStyle(el);
opacity *= parseFloat(cs.opacity);
if (el === tip) continue;
if ([cs.overflowX, cs.overflowY].every((o) => o === "visible")) continue;
const r = el.getBoundingClientRect();
const out = Math.max(r.left - box.left, box.right - r.right, r.top - box.top, box.bottom - r.bottom, 0);
if (out > 0.5 && !clipped) clipped = { by: el.className || el.tagName, px: out };
}
const outside = Math.max(view.left - box.left, box.right - view.right, view.top - box.top, box.bottom - view.bottom, 0);
const prior = tip.style.pointerEvents;
tip.style.pointerEvents = "auto";
const top = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
tip.style.pointerEvents = prior;
const covered = top && !tip.contains(top) ? top.getAttribute("class") || top.tagName : null;
return { covered, opacity, visibility: getComputedStyle(tip).visibility, clipped, outside, width: box.width, text: tip.textContent };
}, index);
const browser = await chromium.launch();
for (const scheme of ["light", "dark"]) {
for (const mode of ["on", "collapsed"]) {
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=2`, { waitUntil: "networkidle" });
await page.waitForSelector(".app");
await page.evaluate(() => document.fonts.ready);
if (mode === "collapsed") {
const edge = await page.evaluate(() => document.querySelector(".nav").getBoundingClientRect().right);
check(`${scheme}/collapsed: rail is off screen while the workspace rail is open`, edge <= 0.5, `right edge ${edge}px`);
await page.click('[data-action="chrome.rail"]');
await page.waitForTimeout(600);
}
const count = await page.locator(".nav .navbtn").count();
check(`${scheme}/${mode}: rail has icons`, count >= 6, `${count}`);
for (let i = 0; i < count; i++) {
const btn = page.locator(".nav .navbtn").nth(i);
await btn.hover();
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; }
check(`${tag}: hint drawn`, m.opacity === 1 && m.visibility === "visible", `opacity ${m.opacity.toFixed(2)}`);
check(`${tag}: hint inside window`, m.outside <= 0.5, `${m.outside.toFixed(1)}px out`);
check(`${tag}: no ancestor clips the hint`, !m.clipped, m.clipped ? `${m.clipped.by} clips ${m.clipped.px.toFixed(1)}px` : "");
check(`${tag}: nothing paints over the hint`, !m.covered, m.covered ? `${m.covered} is on top` : "");
if (SHOTS && (i === 0 || i === count - 1)) {
await page.screenshot({ path: `${SHOTS}-${scheme}-${mode}-${i}.png`, clip: { x: 0, y: 0, width: 420, height: 900 } });
}
}
await page.close();
}
const off = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
await off.addInitScript(() => localStorage.setItem("rx-nav-rail", "off"));
await off.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await off.waitForSelector(".app");
check(`${scheme}/off: no rail drawn`, (await off.locator(".nav").count()) === 0);
await off.close();
const phone = await browser.newPage({ viewport: { width: 420, height: 900 }, colorScheme: scheme, reducedMotion: "reduce" });
await phone.goto(`${BASE}?pref=en&turns=2`, { waitUntil: "networkidle" });
await phone.waitForSelector(".app");
check(`${scheme}/420px: no rail drawn`, (await phone.locator(".nav").count()) === 0);
await phone.close();
}
await browser.close();
if (fails.length) {
console.log(`\n${fails.length} failed`);
process.exit(1);
}
+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);
}
+111
View File
@@ -0,0 +1,111 @@
// The rewrite box of a sent message: a long paragraph with no line breaks must
// be readable in full without scrolling inside the box, at desktop and phone width.
import { chromium } from "playwright";
import { fileURLToPath } from "node:url";
import { mkdirSync } from "node:fs";
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
const SHOTS = process.env.REASK_SHOTS ?? fileURLToPath(new URL("shots", import.meta.url));
mkdirSync(SHOTS, { recursive: true });
const TAG = process.env.REASK_TAG ?? "now";
const LONG = "请把这个模块里所有的错误处理统一改成带类型的错误,并且保证调用方可以用 errors.Is 区分每一种失败,同时补上对应的单元测试,再检查文档里提到旧行为的地方是否需要更新,最后给出一份简短的迁移说明,列出每个受影响的函数以及它们各自需要调整的调用点。".repeat(3);
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, height] of [["wide", 1440, 900], ["phone", 390, 800]]) {
const ctx = await browser.newContext({ locale: "zh-CN", colorScheme: scheme, viewport: { width, height } });
const page = await ctx.newPage();
page.on("pageerror", (e) => fails.push("pageerror: " + e.message));
await page.goto(PAGE, { waitUntil: "networkidle" });
await page.waitForSelector(".app", { timeout: 15000 });
await page.waitForTimeout(600);
const composer = page.locator(".compose textarea").first();
await composer.fill(LONG);
await composer.press("Enter");
await page.waitForTimeout(900);
await page.evaluate(() => window.__feed({ kind: "turn_started", authoredTurn: 1, msgIndex: 1 }));
await page.waitForTimeout(700);
await page.locator(".reask-open").last().click({ force: true });
await page.waitForTimeout(500);
const g = await page.evaluate(() => {
const el = document.querySelector(".reask textarea");
if (!el) return null;
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`);
}
if (g) {
const kb = Math.round(height * 0.5);
await page.setViewportSize({ width, height: kb });
await page.waitForTimeout(300);
await page.locator(".reask textarea").focus();
await page.keyboard.press("Control+End");
await page.waitForTimeout(200);
const k = await page.evaluate(() => {
const el = document.querySelector(".reask textarea");
const r = el.getBoundingClientRect();
return { h: r.height, view: innerHeight, caretAtEnd: el.selectionStart === el.value.length };
});
check(`${scheme}/${label}: with the viewport shrunk to ${kb}px the box stays within 60% of it and the caret stays at the end`, k.h <= k.view * 0.6 + 2 && k.caretAtEnd, `box ${Math.round(k.h)}px of ${k.view}px`);
await page.setViewportSize({ width, height });
await page.waitForTimeout(300);
}
await page.locator(".reask").scrollIntoViewIfNeeded();
await page.screenshot({ path: `${SHOTS}/reask-${TAG}-${scheme}-${label}.png` });
await ctx.close();
}
}
for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height } });
const page = await ctx.newPage();
await page.goto(`${PAGE}?queue=2&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator('.queue .qi').first().locator('button:has-text("改")').click();
await page.waitForTimeout(600);
const q = await page.evaluate(() => {
const el = document.querySelector(".queue .qedit");
if (!el) return null;
const room = el.closest(".qitems")?.clientHeight ?? Infinity;
return { client: el.clientHeight, scroll: el.scrollHeight, cap: parseFloat(getComputedStyle(el).maxHeight), room };
});
check(`queue/${label}: the inline editor opened`, !!q);
if (q) check(`queue/${label}: a long single line fills the editor up to its cap or the room the list leaves`, q.cap >= 100 && q.client >= Math.min(q.scroll, q.cap * 0.9, q.room - 24), `editor ${q.client}px, content ${q.scroll}px, cap ${q.cap}px, room ${q.room}px`);
await ctx.close();
}
for (const [label, width, height] of [["wide", 1440, 900], ["phone", 390, 800]]) {
for (const [what, fill] of [["long line", null], ["sixty lines", Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join("\n")]]) {
const ctx = await browser.newContext({ locale: "zh-CN", viewport: { width, height } });
const page = await ctx.newPage();
await page.goto(`${PAGE}?queue=4&queuebody`, { waitUntil: "networkidle" });
await page.waitForSelector(".queue .qi", { timeout: 15000 });
await page.waitForTimeout(600);
await page.locator(".queue .qi").nth(1).locator('button:has-text("改")').click();
await page.waitForTimeout(600);
if (fill) { await page.locator(".queue .qedit").fill(fill); await page.waitForTimeout(400); }
const g = await page.evaluate(() => {
const el = document.querySelector(".queue .qedit");
if (!el) return null;
const list = el.closest(".qitems").getBoundingClientRect();
const row = el.closest(".qi").getBoundingClientRect();
return { top: row.top - list.top, bottom: list.bottom - row.bottom, room: list.height, row: row.height };
});
check(`queue/${label}/${what}: the editor row sits whole inside the list's visible area`, !!g && g.top >= -0.5 && g.bottom >= -0.5, g ? `row ${Math.round(g.row)}px in ${Math.round(g.room)}px, ${Math.round(g.top)}px above / ${Math.round(g.bottom)}px below` : "");
await ctx.close();
}
}
await browser.close();
console.log(fails.length ? `\n${fails.length} failed` : "\nall passed");
process.exit(fails.length ? 1 : 0);
@@ -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);
+19 -4
View File
@@ -12,8 +12,11 @@ const GUARDS = readdirSync("perf")
.filter((f) => HAS_FAILS.test(readFileSync(`perf/${f}`, "utf8")))
.map((f) => f.slice(0, -4))
.sort();
const selected = [...new Set(process.argv.slice(2))];
const unknown = selected.filter((g) => !GUARDS.includes(g));
if (unknown.length) throw new Error(`Unknown guards: ${unknown.join(", ")}`);
const rows = [];
for (const g of GUARDS) {
for (const g of selected.length ? selected : GUARDS) {
const path = `perf/${g}.mjs`;
const src = readFileSync(path, "utf8");
// An exit code alone cannot tell a fired assertion from a file that does not
@@ -33,13 +36,25 @@ for (const g of GUARDS) {
const tmp = `perf/_probe_${g}.mjs`; // 跑完就删
writeFileSync(tmp, probe);
let code = 0;
let stdout = "";
try {
execFileSync("node", [tmp], { stdio: "pipe" });
stdout = execFileSync("node", [tmp], { stdio: "pipe", encoding: "utf8" });
} catch (e) {
code = e.status ?? -1;
stdout = e.stdout ?? "";
} finally {
unlinkSync(tmp);
}
unlinkSync(tmp);
rows.push({ 守卫: g, "塞入必假断言后的退出码": code, 结果: code !== 0 ? "能红 ✓" : "仍然绿 ✗" });
// This guard emits its assertion result only after every browser case finishes.
// A launch error or unrelated exception cannot substitute for that verdict.
const fired = g !== "branch-tooltip" || code === 1 && stdout.split("\n").some((line) => {
try {
const report = JSON.parse(line);
return report.guard === g && Array.isArray(report.failures) &&
report.failures.length === 1 && report.failures[0] === "__自检__";
} catch { return false; }
});
rows.push({ 守卫: g, "塞入必假断言后的退出码": code, 结果: code === 0 ? "仍然绿 ✗" : fired ? "能红 ✓" : "未到达注入断言 ✗" });
}
console.table(rows);
const broken = rows.filter((r) => r.结果 !== "能红 ✓");
+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");
Loaded 100 of 1250 files, more files were not shown because too many files have changed in this diff. Show more