Compare commits

...
82 Commits
Author SHA1 Message Date
YHH 5d88b179b2 Merge pull request #11156 from esengine/docs/v1.39.4-notes
docs(release): prepare v1.39.4 notes
2026-09-28 00:40:41 -07:00
esengine 1132215f4a docs(release): prepare v1.39.4 notes 2026-09-28 00:39:28 -07:00
YHH d9ba90ed04 Merge pull request #11037 from esengine/dependabot/go_modules/main-v2/github.com/modelcontextprotocol/go-sdk-1.8.0
build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.8.0-pre.2 to 1.8.0
2026-09-27 23:41:28 -07:00
esengine 4a09d5546b build(deps): tidy the desktop module for go-sdk 1.8.0 2026-09-27 23:26:08 -07:00
dependabot[bot] 4a5f86a0d1 build(deps): bump github.com/modelcontextprotocol/go-sdk
Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.8.0-pre.2 to 1.8.0.
- [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/go-sdk/compare/v1.8.0-pre.2...v1.8.0)

---
updated-dependencies:
- dependency-name: github.com/modelcontextprotocol/go-sdk
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-28 05:55:05 +00:00
YHH 555b96966c Merge pull request #11144 from esengine/fix/v1-chat-tui-budget
refactor(cli): move the custom status-line command out of chat_tui.go
2026-09-27 22:38:25 -07:00
esengine 790c6be08e refactor(cli): move the custom status-line command into its own file
chat_tui.go crossed its repolint file-size budget once #11114 and #11062
landed together. The status-line command runner is self-contained, so it
moves verbatim to chat_tui_statusline.go; no behaviour changes.

The freed budget is reclaimed for chat_tui.go only (file-size 4112 -> 4067,
function-size 1056 -> 1030, complexity 274 -> 271, essay 77 -> 75) and the
repo-wide limits drop by the same amounts.
2026-09-27 22:03:08 -07:00
YHH 52f465e4f5 Merge pull request #11038 from esengine/dependabot/npm_and_yarn/desktop/frontend/main-v2/npm-1x-60baa74da5
build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
2026-09-27 21:04:35 -07:00
YHH 3d129f0d6f Merge pull request #11036 from esengine/dependabot/go_modules/main-v2/go-1x-b6c41911f7
build(deps): bump the go-1x group with 5 updates
2026-09-27 21:03:13 -07:00
YHH e66c50c76b Merge pull request #10949 from boscocp/fix/v1-6692-validate-rule-tool-names
fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
2026-09-27 21:02:31 -07:00
YHH b474d12fdd Merge pull request #11076 from esengine/fix/v1-8327-deterministic-reset-fixture
test(openai): pin the body-phase reset fixture to the body phase
2026-09-27 21:01:48 -07:00
YHH b66fdb30f6 Merge pull request #11089 from esengine/fix/v1-9029-drop-into-group
fix(desktop): dropping a session on a group member row adds it to that group
2026-09-27 21:01:03 -07:00
YHH 092ab722cc Merge pull request #11085 from esengine/fix/v1-8859-needs-checks-chime
fix(desktop): ring the attention chime when a turn stops for delivery checks
2026-09-27 21:00:18 -07:00
YHH 390f996103 Merge pull request #11122 from esengine/fix/v1-11010-rotate-typed-key-in-place
fix(setup): rotate a key in place when its providers alone read the variable
2026-09-27 20:59:32 -07:00
YHH 46dd766262 Merge pull request #11093 from esengine/fix/v1-9652-review-step-cap
fix(agent): stop capping review sub-agents at eight rounds
2026-09-27 20:58:46 -07:00
YHH 4cf81e89fe Merge pull request #11062 from esengine/fix/v1-4806-mcp-retry-async
fix(cli): run MCP manager connects off the UI loop
2026-09-27 20:58:01 -07:00
YHH 63582bc7c8 Merge pull request #11092 from KHG420/codex/1x-remote-context-panel
fix(desktop): load remote session context in overview
2026-09-27 20:57:19 -07:00
YHH 54c73168c3 Merge pull request #11131 from esengine/docs/v1.39.3-notes
docs(release): prepare v1.39.3 notes
2026-09-27 19:38:39 -07:00
esengine 36c1faba14 docs(release): prepare v1.39.3 notes 2026-09-27 19:33:09 -07:00
YHH 1808ff6d63 Merge pull request #11124 from esengine/fix/v1-remote-transcript-v2
fix(serve): advertise transcript-v2 whenever the foreground can follow
2026-09-27 19:30:53 -07:00
YHH 9c3123c7a3 Merge pull request #11114 from esengine/fix/v1-hardening-0928
fix: harden project config, serve approvals, host git and sandboxed git metadata
2026-09-27 19:23:42 -07:00
esengine aecf4e6960 fix(serve): advertise transcript-v2 whenever the foreground can follow
The handshake announced transcript-v2 only while the serve's foreground
controller was bound to an exclusive (canonical) session. Opening a
historical path-backed session over SSH makes POST /resume swap the
foreground for a native-legacy controller, and from then on every
/auth/token answer dropped transcript-v2 even though GET
/transcript/follow keeps serving that controller through the legacy
projection. Any remote tab that attached or reconnected in that state
kept a capability set without transcript-v2 for its lifetime, so the
desktop refused Follow before sending a request ("transcript v2 is
required; upgrade Serve and Desktop together") and the composer refused
to send ("Transcript v2 is not synchronized"). The detached serve
survives desktop restarts, so the state persisted across upgrades.

transcript-v2 is now announced from control.TranscriptFollowAPI, the
same interface the route enforces, as transcript-outline-v1 already is.

ServeCapsToken is bumped so a desktop carrying this fix retires a live
serve started from an older binary and replaces a remote binary that
still advertises the old token; otherwise a reused serve would keep the
old handshake until it happened to exit. The same bump retires 1.38.0 to
1.38.9 binaries, which carried the previous token but predate Follow v2.

Test: TestRemoteFollowAttachesToServeWithPathBackedForeground puts a
path-backed controller in a real serve, performs the real token
handshake and follows through RemoteTranscriptFollowForTab. Before the
change it fails with "transcript v2 is required"; after it, the follow
returns a protocol-2 snapshot from the legacy backend.

Fixes #11118
Fixes #11105
2026-09-27 18:58:09 -07:00
esengine d4e11410cb perf(gitcmd): skip the driver listing for plumbing that converts no content
Every hardened invocation listed the repository's filter and merge drivers
with a second git process first, doubling the process count of host git
work. On Windows, where a process start is expensive, that pushed the
worktree suite past its 8-minute limit.

rev-parse, rev-list, merge-base, the ref commands, write-tree and
commit-tree never pass content through a filter or merge driver and never
honour submodule.recurse, so any overrides the listing produced were inert
for them. They now run without it; every subcommand that can convert
content keeps the listing and still fails closed on an unaddressable
driver name.
2026-09-27 18:57:32 -07:00
esengine 2f7809c10d fix(setup): rotate a key in place when its providers alone read the variable
Saving a new key for a provider that already had one always moved the
provider to a fresh REASONIX_CONNECTION_*_KEY slot and left the previous
value under the old variable. A name the user typed at setup (kept since
#11005) therefore went stale on the first rotation, and scripts reading
it kept seeing the old key.

RotateModelCredentialLocked rewrites the variable in place when the edit
targets the user config, the providers being saved are its only readers
there before and after the edit, no bot or remote-host setting reads it,
and the global .env already holds its value. A project reasonix.toml
cannot see the user config's readers, so its providers never rotate.
Anything else still gets a private slot.

Rollback needs the previous value back. It is written to the global .env
under a random REASONIX_ROTATION_*_KEY in the same atomic file write as
the new value, so it has exactly the protection every stored key has;
the journal records only the two names and a keyed digest of the new
value. Cleanup of a failed save and crash recovery restore the previous
value only while the variable still holds the digest this edit wrote,
then drop the backup; a published edit drops the backup and keeps the
new value.

Used by the CLI setup commit, CommitConnectionCredential and the
desktop's journalled credential edits. Desktop edits that run without a
journal keep minting private slots, since they have nothing to restore
from.

Closes #11010
2026-09-27 18:52:02 -07:00
esengine d2cb0aa894 fix(sandbox): collapse over-budget git groups to one bwrap mount
An over-budget worktrees/ or modules/ group was bound read-only as a whole,
but the exact mounts for the gitdirs inside it were still emitted first, so
planting submodule gitdirs kept growing the bubblewrap argument list. Paths
beneath a read-only tree are now left to that tree.

The serve launch-token sandbox test placed its files under /tmp outside the
write root, which bubblewrap masks with a tmpfs: the control read failed and
the denied reads passed without exercising the forbid-read mounts. The files
now live inside the write root on every platform.

The auth-disabled serve launch-token test now closes the process-wide usage
catalog serve.New starts before its REASONIX_HOME is removed, which Windows
otherwise refuses. The partial-clone test compares errors with errors.Is.
2026-09-27 18:37:19 -07:00
esengine 3cb73b3bf4 fix(sandbox): keep repository git config and hooks read-only inside the bash sandbox
The host's own git reads the workspace repository's configuration and hooks,
so a confined command that can rewrite them can make the host run a program
of its choosing. The OS sandbox now denies writes to them while leaving the
rest of .git writable.

For the repository git itself discovers from each writable root, protected
paths are: config, config.worktree, commondir and hooks/ of the gitdir and
of the common dir; config, config.worktree and commondir of every existing
linked worktree entry (new entries stay creatable); config, config.worktree,
commondir and hooks/ of every submodule gitdir under modules/. A .git file is followed to the gitdir it names
exactly as git resolves it, and the pointer file and commondir are
protected, so a repository cannot aim the protection at a decoy while git
reads something else. The .git entry, the gitdir, the common dir, every
symlink on the way to them and each writable ancestor are pinned: they
cannot be removed, renamed away or replaced by a symlink, while their own
mode and times stay writable (touch and chmod on the workspace root work).

- macOS: deny rules after every write allowance. Existing worktree and
  submodule gitdirs (up to 128 each, found without following symlinks) get
  exact rules and pins; patterns anchored at the common dir cover the rest
  and gitdirs created later, skipping refs/ and logs/ segments so a branch
  or tag named config or hooks stays writable. The profile stops growing
  past the limit.
- Linux: pinned directories become their own bind mounts and protected files
  and hook trees are bound read-only; only real directories and regular
  files are mount targets. Up to 512 gitdirs per group get exact mounts;
  past that the whole group directory is bound read-only, so planted HEAD
  files cannot overflow the argument list. Only existing paths can be
  mounted and symlinks cannot be pinned.
- Windows has no shell sandbox; the guide says so.

A protected file with another hard link refuses the confined launch with
sandbox.git_metadata_linked (ErrGitMetadataLinked), since no path rule sees
a write through the other name. Whenever a confined command's output names
a protected path, also on exit 0 (git reports some refused config writes
and exits 0), the bash result carries sandbox.git_metadata_protected.

Real-sandbox tests cover each protected path, pointer and symlink
retargeting, patterns for gitdirs created later, bounded rule size with
2000 submodule gitdirs, ordinary git work, and the git operations that now
fail or silently skip their config write.
2026-09-27 17:35:16 -07:00
esengine ec866730e0 fix(gitcmd): host git never runs repository-configured programs
Every git invocation the host makes goes through gitcmd, and each one must
leave the programs a repository's own configuration names unexecuted:
filter and merge drivers, textconv and external diff, fsmonitor, hooks,
signature verifiers, submodule-boundary programs and lazy-fetch transport.
Driver neutralization now covers every subcommand, not diff alone, and takes
driver names from git itself rather than from a separate reading of .git/config.

- Fixed keys are overridden with -c: core.fsmonitor (empty, not "false",
  which git before 2.35.2 reads as a hook name), core.hooksPath,
  log.showSignature, merge.verifySignatures, diff.submodule, gc.auto and
  maintenance.auto and safe.bareRepository=explicit; submodule.recurse=false
  for checkout, switch, restore, reset, merge and read-tree (host branch
  switches and merges never update submodules, whatever the user's global
  submodule.recurse), and elsewhere only where the repository's own config
  sets it.
  GIT_NO_LAZY_FETCH=1 stops a missing object becoming a fetch through the
  repository's remote (git 2.44+); a historical patch that fails for that
  reason returns gitcmd.ErrObjectNotLocal, decided by git's own
  --missing=print listing.
- diff, log and show carry --no-ext-diff and --no-textconv; status and diff
  carry --ignore-submodules=dirty. The subcommand is found past global
  options, so a call passing -C inside args is hardened too.
- Filter and merge drivers defined at local or worktree scope are listed by
  git itself before each invocation (includes followed) and overridden empty;
  user and system scope stay live; a name -c cannot address fails closed
  (ErrRepositoryDrivers).
- worktree add uses --no-checkout and populates through gitcmd inside the new
  worktree, so a conditional include keyed on the new gitdir or branch is
  seen by the driver listing there rather than run by the add's checkout.
  The reset takes the worktree-mutation timeout, and a failed reset removes
  both the worktree and the branch the add created.
  worktree remove uses --force after the existing hardened clean-tree check,
  so git does not run status inside the linked worktree with its config.
- Launcher ls-remote runs Detached with gitcmd's environment (only PATH is
  taken from the launcher's declared env), requires an https://, ssh:// or
  git:// remote (case-insensitive; http:// and local paths are refused), and
  passes the remote and ref past -- so a leading-dash value is an operand.
- worktree git calls with extra environment keep gitcmd's environment.

- Repository identity is resolved once, when a session opens: boot runs
  gitcmd.Open (rev-parse --absolute-git-dir --git-common-dir --show-toplevel)
  before any command, the controller carries the resulting gitcmd.Repo, and a
  rebuild of the same workspace passes it on. Every later host call runs
  through the Repo with GIT_DIR, GIT_COMMON_DIR and GIT_WORK_TREE set, so a
  nested .git, a commondir file, a rewritten .git file or a damaged HEAD
  written afterwards cannot substitute another repository or its config; a
  damaged HEAD fails closed. Threaded through the TUI status line, cli
  review (resolved once before the review agent runs), the desktop changes
  panel, change detail, history, commit detail, branch list/checkout/create
  and tab branch label (the tab session's identity; a tab with no session
  yet resolves at display), worktree Inspect/Create/RollbackCreate, and the
  whole merge-back flow: the source and worktree identities are recorded in
  the merge metadata at creation (the worktree's resolved right after
  worktree add) in metadata.repo.json beside metadata.json, whose shape
  older builds decode strictly and so is unchanged; every inspect,
  auto-commit, merge and cleanup call runs against them, and a worktree
  without that file resolves them from the roots.
  InspectMerge finds the allocation root from the managed layout instead of
  asking git in the worktree.
- Repo.Verify applies Open's test to a stored identity; the identities read
  from metadata.repo.json are verified before the merge flow uses them.
- Auto-commit staging uses gitcmd Repo.StageAll: gitlinks present as
  directories and untracked nested repositories are excluded with
  :(exclude,literal) pathspecs, because add -A checks a submodule by running
  git inside it under its own config. A gitlink keeps the commit the index
  records; an untracked nested repository leaves the worktree unverifiable
  and the auto-commit refuses. The worktree state token no longer runs git
  inside a changed nested repository.
- Open accepts only a git dir belonging to the work tree that holds the
  directory: the work tree's own .git (sharing no other common dir) or a
  linked worktree's admin dir under <common>/worktrees whose gitdir file
  names that work tree's .git. A .git file or core.worktree pointing at
  another checkout is ErrNotRepository. Submodule checkouts and
  --separate-git-dir repositories opened directly are therefore treated as
  not version-controlled.

Regression tests use real git with payload scripts: the gitcmd config-key
matrix (signatures, lazy fetch, submodule boundary), the worktree call sites
(conditional includes on Create, linked-worktree config on RollbackCreate),
the status readout, the launcher's non-URL remote rejection, identity
pinning after a nested .git, a commondir file and a damaged HEAD (config
origin and filter marker), Open refusing another checkout's git dir (.git
file, core.worktree, foreign admin dir), StageAll with nested repositories
named like pathspec magic or globs, metadata.json read by a strict older
decoder, merge-back without the identity file, StageAll and merge-back auto-commit past a gitlink
with its own filter, merge-back after the worktree's .git file is pointed at
another git dir, the status line and desktop changes panel of a workspace
that later gains its own .git, and a global submodule.recurse=true against
host checkout and reset. The list-then-run race is left to a follow-up that
denies sandboxed writes to .git config and hooks.
2026-09-27 17:35:16 -07:00
esengine 3065db7a49 fix(serve): changes and approvals require the launch token
With authentication off (still `serve`'s default), reads stay open but every
state-changing request needs the per-launch token, as an Authorization bearer
or the cookie /auth/token issues, and otherwise refuses with 403
`launch_token_required`. The gate runs inside the host and CSRF guards, and a
configured token is honoured so a fronting proxy can inject it. Token mode also
accepts the bearer header, and the web UI explains the refusal.

The token never reaches the terminal: in both token and none modes serve writes
it to a 0600 file under the remote state directory and prints the path, or
names the --token-file of a managed launch. Runtime sandboxes and read tools
deny that directory and any registered token file.

[serve] is read from the user config only, so a project reasonix.toml cannot
choose the token, the auth mode, or trust in forwarded headers.
2026-09-27 17:35:00 -07:00
esengine ae2e9053f5 fix(mcp): project-declared MCP servers and the statusline wait for the user
MCP servers a workspace's reasonix.toml or .mcp.json declares, and the
[statusline] command, come with the checkout. Neither may start before the
user decides; this change covers those two surfaces only.

MCP servers:
- Without a recorded decision a project-declared server (stdio command or
  HTTP/SSE URL) is off. User-level servers keep following auto_start. An
  unreadable activation store and an entry of unknown provenance fail closed.
- A decision holds for the declaration it was made on: the activation row
  stores a digest of the declaration as written, the project .env values it
  expands, and the content of workspace files named directly as its command
  or arguments. A change to any of these reads as changed_since_enabled and
  keeps the server off. Files loaded indirectly, PATH-resolved interpreters
  and unpinned package launchers are outside the digest.
- Edits made through Reasonix at the user's request (desktop edit, tier,
  clear-auth, the /mcp mode and clear-auth actions) carry the decision only
  when the file afterwards holds the declaration that edit wrote and the
  .env values and named files it draws on read as in the approved state.
- MCPDecision is typed: enabled, disabled, awaiting_user_decision,
  changed_since_enabled. boot.Build emits one notice per process for each
  such server; `reasonix mcp list` shows the state; the model's refusal
  carries the code instead of "disabled in this session". The launch line
  names every env key, shows values of loader, interpreter and package-index
  variables, redacts endpoint credentials, and points to `reasonix mcp get`.
- `reasonix mcp enable/list/get` resolve the workspace a session there would
  use (nearest git root), and enable prints the approved launch line.
- Starting a project server by hand (/mcp connect, desktop reconnect or tier)
  records the decision. boot.Build, and with it CLI/TUI, `reasonix run`,
  serve, ACP, bot and desktop tabs, reads the same state, as do the /mcp
  view, desktop capabilities, controller fallbacks and `doctor --live`.
- A project .env expands only project-declared servers.

Statusline: user-level only. LoadForRoot restores the user value after the
project merge, project renders never write it, and the TUI spawns it with
NoDefaultCurrentDirectoryInExePath set.
2026-09-27 17:35:00 -07:00
esengine c2d10fb7e0 fix(agent): stop capping review sub-agents at eight rounds
A review, security-review or team-architect sub-agent paused after 8
tool-call rounds whenever the call did not pass max_steps, which is how
every skill-run review and most model-dispatched reviews start. The pause
message tells the user to raise max_steps, but no setting reaches this
budget, so a multi-file review could not finish at all (#9652).

Root cause: applyReviewBudget stamped a hardcoded defaultReviewMaxSteps = 8
on review-family children, and PrepareReviewSubagentContext handed the same
8 to skill runners, overriding the step budget the runner had already
resolved from the parent. Under the default uncapped parent that turned
"no limit" into 8.

Fix: the review contract keeps what it is for (the 2048-token cap per
response, the structured output format and the parent facts pack) and no
longer sets a step budget. Review children take the ordinary child budget:
an explicit max_steps wins, otherwise half the parent's cap, or uncapped
under an uncapped parent. The skill path keeps the runner's own steps.

Test: TestReviewSubagentUnderUncappedParentFinishesPastEightRounds runs a
real review task through TaskTool.Execute with a scripted 12-round child;
before the fix it fails with "sub-agent: paused after 8 tool-call rounds",
after it returns the verdict. TestReviewBudgetKeepsInheritedStepsAndBoundsOutput
(steps = 8, want 0 before) and TestReviewSkillKeepsRunnerStepBudget
("runner steps 0 reached the child as 8" before) cover the task and skill
budgets. go test ./internal/agent/ ./internal/boot/, go vet, repolint and
golangci-lint on both packages pass on Windows.
2026-09-27 09:35:34 -07:00
esengine 053136c488 fix(desktop): dropping a session on a group member row adds it to that group
A drop on a member row of a session group ran the row's reorder, so a
session dragged from outside the group stayed outside it; only the group
header accepted drop-to-group. The row drop now reads the target row's
group from the organization state: a session that is not yet a member is
set into that group, and a session already in it is reordered as before.
2026-09-27 09:15:30 -07:00
xiexian 49939444ff fix(desktop): load remote session context in overview 2026-09-27 23:57:53 +08:00
esengine 3a946c048d fix(desktop): ring the attention chime when a turn stops for delivery checks
A turn that ends with the final-readiness check failing ("need checks"),
or with another typed pause, finished silently: the runtime notification
owner played the success chime only when turn_done carried no error, and
these pauses carry the typed pause as their error. The user got no
signal that the turn was over and waiting on them.

turn_done now reads its structured outcome: final_readiness,
recovery_paused, incomplete_read and completion_uncertain - the outcomes
desktop/topic_status.go records as paused or awaiting_delivery - ring
the attention chime ("Awaiting response"). A clean finish keeps the
success chime and an ordinary failure stays silent.

Test: runtime-turn-done-chime.test.ts drives createRuntimeNotifications
with a fake AudioContext. On the base it fails ("a turn that stopped for
delivery checks must alert the user": 0 !== 1); with the fix it passes.
2026-09-27 08:47:03 -07:00
esengine 54f4898b2c test(openai): pin the body-phase reset fixture to the body phase
TestStreamSurfacesEarlyConnResetAsInterrupt and
TestStreamDoesNotReplayAfterOutput forced a real TCP RST right after
writing the 200 head. Whether net/http observes that RST before or after
the head is up to the OS scheduler (#8327). When it lands first the
failure is a request error returned by Stream itself, not a body-phase
interruption, so the test fails on "Stream:" instead of exercising the
contract it names. On the studio line, which still retries that phase,
the same race shows up as extra server requests.

The fixture is now a scripted RoundTripper: a 200 SSE response whose body
yields the prelude and then fails with a net.OpError(ECONNRESET). The
phase is fixed, the request counter is atomic, and no socket is involved.

Test: both tests pass 500/500 on Windows. Disabling the IsConnReset branch
in streamOnce makes both fail, so the fixture still guards the contract.
2026-09-27 08:26:46 -07:00
esengine 03254360ee fix(cli): run MCP manager connects off the UI loop
Choosing Retry (or Reconnect / Enable and connect) on a server in the /mcp
manager called ConnectConfiguredMCPServer synchronously inside the Bubble
Tea Update. The initialize + tools/list handshake is bounded only by the
server's startup timeout (30s by default), so an unresponsive server froze
every keypress and redraw of the chat until the handshake gave up, which
reads as a hang. Saving a connection mode and the reconnect after MCP
OAuth made the same synchronous call.

All three now start the connect as a tea.Cmd and report back through
mcpConnectDoneMsg, whose cause picks the notice. chatTUI.mcpConnecting
records servers with a connect in flight; it lives on the chat model
rather than the manager, so closing and reopening /mcp cannot start a
second handshake. While a server is connecting, Mode, Retry, Auth, Clear
auth, Disable and Remove in the manager, and /mcp connect|remove, are
refused, so a late result cannot re-enable something the user just turned
off. If a connect still succeeds for a server that is no longer configured
(for example the config was edited meanwhile) or is disabled for the
session, the result disconnects it instead of keeping it live. The result
refreshes the manager without moving it to another page.

Test (internal/cli/mcp_manager_connect_test.go, gated HTTP MCP fake):
- TestMCPManagerRetryDoesNotBlockTheUILoop: on main-v2 applyMCPAction held
  the loop for the full startup timeout (3.0s with a 3s timeout) and
  failed. Now it returns at once; after closing and reopening /mcp a
  second Retry is refused; Disable and Remove are refused while in flight;
  the successful result clears the marker, leaves the manager's stage, and
  a Disable afterwards takes effect.
- TestMCPConnectFinishingAfterRemovalIsDropped: the entry is removed from
  reasonix.toml while initialize is held; the finished connect is
  disconnected. Without the completion check it stayed live (fails).
- TestApplyMCPModeRecordsPluginConnectFailure (moved from mcp_test.go)
  now drives the returned command and still records the host failure.
2026-09-27 07:34:09 -07:00
esengine 35fc8b746a build(deps): tidy the desktop module for the go-1x bumps
desktop/ replaces reasonix with the parent module, so the root bumps raise
its graph too and its go test stops with "updates to go.mod needed".
go mod tidy in desktop/ moves only the versions the root already requires.
2026-09-27 07:22:11 -07:00
esengine befb5e258c build(deps): sync desktop/pnpm-lock.yaml with the npm-1x bumps
Dependabot updated desktop/frontend/package.json but not the workspace
lockfile at desktop/pnpm-lock.yaml, so every frozen install failed with
ERR_PNPM_OUTDATED_LOCKFILE. Regenerated with pnpm 10.34.5 --lockfile-only;
only the four bumped packages and their own dependencies move.
2026-09-27 07:10:35 -07:00
Adalberto Pereira 8e9f7cfed0 fix(permission): name an unclosed subject and stop widening allow rules / 提示未闭合括号并避免放宽 allow 规则
A rule missing its closing parenthesis is now reported as that typo and
repaired instead of being wrapped in a second Bash(...) that parses but
still matches nothing, and a bare command on the allow list is suggested
in its exact form, since the prefix form would also grant every argument.
2026-09-27 10:58:48 -03:00
YHH 96297a2146 Merge pull request #11047 from KHG420/codex/1x-acp-skill-raw
fix(acp): preserve typed skill input in 1.x turns
2026-09-27 06:41:47 -07:00
YHH 48b340a877 Merge pull request #11051 from esengine/fix/v1-release-without-website
ci(release): publish 1.x without deploying or verifying the website
2026-09-27 06:16:22 -07:00
esengine d67f2835ac ci(release): publish 1.x without deploying or verifying the website
The website is released from its own branch and renders the changelog from
that branch's catalog, so the 1.x publication could dispatch Pages and still
fail on a changelog page it does not own (v1.39.2: "public changelog returned
HTTP 404" after every product surface had shipped).

- Postflight now verifies tags, CLI and Desktop releases, npm, the Desktop
  Stable manifest on dl.reasonix.io and the Homebrew cask, attaches
  release-event.json, and completes. sync-release-site.sh becomes
  reconcile-release-publication.sh without the Pages dispatch/watch stages.
- The stable manifest check stays: it reads dl.reasonix.io/latest/latest.json,
  the Desktop updater pointer on R2, not the website. The Homebrew check moves
  out of the site verifier and reads the tap through the contents API.
- Drop the homepage/changelog/hydrated-DOM probes, observe-release-site.sh,
  release-site-fetch, and the pages.yml dispatches in release-stable.yml and
  release-desktop.yml together with the actions: write they needed.
- Ledger: the site fragment becomes a pointer fragment (stableManifest,
  homebrew); homepage and changelog surfaces are gone.
2026-09-27 05:55:11 -07:00
xiexian 6512f0db40 fix(acp): exclude resolved 1.x skills from routing 2026-09-27 20:35:52 +08:00
dependabot[bot] a0ac937c65 build(deps): bump the npm-1x group in /desktop/frontend with 4 updates
Bumps the npm-1x group in /desktop/frontend with 4 updates: [@tanstack/react-virtual](https://github.com/TanStack/virtual/tree/HEAD/packages/react-virtual), [katex](https://github.com/KaTeX/KaTeX), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-virtual` from 3.14.11 to 3.14.13
- [Release notes](https://github.com/TanStack/virtual/releases)
- [Changelog](https://github.com/TanStack/virtual/blob/main/packages/react-virtual/CHANGELOG.md)
- [Commits](https://github.com/TanStack/virtual/commits/@tanstack/[email protected]/packages/react-virtual)

Updates `katex` from 0.18.7 to 0.18.9
- [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.7...v0.18.9)

Updates `@typescript-eslint/parser` from 8.70.0 to 8.70.1
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.70.1/packages/parser)

Updates `tsx` from 4.23.11 to 4.23.15
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.11...v4.23.15)

---
updated-dependencies:
- dependency-name: "@tanstack/react-virtual"
  dependency-version: 3.14.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: katex
  dependency-version: 0.18.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.70.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
- dependency-name: tsx
  dependency-version: 4.23.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:54:01 +00:00
dependabot[bot] 15a1f262fb build(deps): bump the go-1x group with 5 updates
Bumps the go-1x group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [charm.land/bubbletea/v2](https://github.com/charmbracelet/bubbletea) | `2.0.9` | `2.0.10` |
| [github.com/bmatcuk/doublestar/v4](https://github.com/bmatcuk/doublestar) | `4.10.0` | `4.10.2` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.4` | `1.18.7` |
| [github.com/mattn/go-runewidth](https://github.com/mattn/go-runewidth) | `0.0.29` | `0.0.30` |
| [mvdan.cc/sh/v3](https://github.com/mvdan/sh) | `3.14.0` | `3.14.1` |


Updates `charm.land/bubbletea/v2` from 2.0.9 to 2.0.10
- [Release notes](https://github.com/charmbracelet/bubbletea/releases)
- [Commits](https://github.com/charmbracelet/bubbletea/compare/v2.0.9...v2.0.10)

Updates `github.com/bmatcuk/doublestar/v4` from 4.10.0 to 4.10.2
- [Release notes](https://github.com/bmatcuk/doublestar/releases)
- [Commits](https://github.com/bmatcuk/doublestar/compare/v4.10.0...v4.10.2)

Updates `github.com/klauspost/compress` from 1.18.4 to 1.18.7
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.4...v1.18.7)

Updates `github.com/mattn/go-runewidth` from 0.0.29 to 0.0.30
- [Commits](https://github.com/mattn/go-runewidth/compare/v0.0.29...v0.0.30)

Updates `mvdan.cc/sh/v3` from 3.14.0 to 3.14.1
- [Release notes](https://github.com/mvdan/sh/releases)
- [Changelog](https://github.com/mvdan/sh/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mvdan/sh/compare/v3.14.0...v3.14.1)

---
updated-dependencies:
- dependency-name: charm.land/bubbletea/v2
  dependency-version: 2.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/bmatcuk/doublestar/v4
  dependency-version: 4.10.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: github.com/mattn/go-runewidth
  dependency-version: 0.0.30
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
- dependency-name: mvdan.cc/sh/v3
  dependency-version: 3.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-1x
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-09-27 11:53:34 +00:00
YHH 7574f8b4c9 Merge pull request #11031 from esengine/docs/v1.39.2-notes
docs(release): prepare v1.39.2 notes
2026-09-27 04:39:01 -07:00
esengine d945284cec docs(release): prepare v1.39.2 notes
Bilingual Stable notes for everything merged to main-v2 since v1.39.1: unopenable and unarchivable sessions, live streaming on legacy-backed sessions, typed skill invocations, the setup API key variable name, and code-page output on Chinese Windows. CI-only changes (#10863, #10870, #10983) are left out.
2026-09-27 04:37:09 -07:00
xiexian c55c6bee78 fix(acp): preserve raw text across slash prompt expansion 2026-09-27 19:30:06 +08:00
YHH 2a3855f16a Merge pull request #11023 from KHG420/codex/1x-inline-skill-raw
fix(skill): route 1.x inline skills from raw user input
2026-09-27 04:20:36 -07:00
YHH 6d0c756228 Merge pull request #11022 from esengine/fix/v1-archived-row-stays-listed
fix(desktop): hide a historical row once any workspace has adopted its source
2026-09-27 03:41:03 -07:00
esengine dd9b441f99 fix(desktop): hide a historical row once any workspace has adopted its source
The sidebar lists a legacy transcript under the scope its sidecar names, and
the persist observer indexes a save under the global fallback when the sidecar
names none. Archive and import assign the owning workspace from the store the
file physically lives in. When the two disagree, the adopted source was only
filtered from the workspace that owns the mapping, so the row stayed listed in
the other one: archiving it "succeeded" again and again, and pin, rename and
open resolved to the archived conversation and were refused with
"Restore this session before ...".

Adoption is a property of the source identity, so the sidebar's adopted-row
filter and the lazy page's path exclusions now consider every mapping, as the
history listing already did. A head-keyed mapping owned by another workspace
that cannot prove a path alias still does not disable the lazy adapter here.
2026-09-27 03:21:47 -07:00
YHH acf3bfdcbc Merge pull request #11003 from KHG420/codex/1x-worktree-git-write-hint
fix(sandbox): guide worktree Git metadata write approval
2026-09-27 03:00:13 -07:00
xiexian 96b296cd90 test(skill): reject slash mentions as implicit invocations 2026-09-27 17:55:14 +08:00
xiexian b18f84ee22 fix(skill): route inline invocations from raw user input 2026-09-27 17:38:47 +08:00
YHH 29c8349c3a Merge pull request #11007 from esengine/fix/v1-11004-inline-skill-pin
fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
2026-09-27 02:26:16 -07:00
YHH 51fe2e9255 Merge pull request #11005 from esengine/fix/v1-11002-api-key-env-name
fix(setup): keep an API key variable name the user types
2026-09-27 02:17:10 -07:00
xiexian 8cf5b64418 test(sandbox): compare JSON-escaped worktree hints on Windows 2026-09-27 17:16:21 +08:00
esengine 463840813f fix(skill): send a user-typed inline skill as the pinned invocation run_skill returns
A bare "/name" line or a lone skill chip reached the provider as the raw
rendered skill body in plain user prose: no <skill-pin> sentinel, and on the
structured (composer) path the typed text trailed as an unlabeled paragraph
instead of an Arguments line. The model-invoked path (run_skill,
read_only_skill, read_skill) already sent the pinned form, so one skill had
two payload shapes depending on who invoked it.

Every invocation site now renders through skill.RenderInvocation: the
string submit path, the structured path, RunSkill (ACP prompts) and the
slash_command tool. A lone inline chip takes the typed text as its
arguments, matching "/name task"; several inline chips, or chips mixed with
subagents, keep appending the shared text once. Nothing moves into the
cache-stable prefix. Store.Render is removed and Render is unexported, so the
pinned form is the only one an invocation can take.

An effect test through the real Build asserts the pinned payload at the
provider boundary for the bare and argument-carrying forms of both paths.

Fixes #11004
2026-09-27 02:09:43 -07:00
esengine c04f7dd247 fix(setup): keep an API key variable name the user types
`reasonix setup` asked for the API key variable name, then saved every
new key under a freshly allocated REASONIX_CONNECTION_*_KEY slot, so a
typed name was dropped from config.toml and .env without a word.

A typed name is now kept when saving under it changes nothing another
reader resolves: no other provider, bot or remote-host setting reads it,
the credential store holds no value or tombstone for it, and the process
environment does not already set it (compared case-insensitively on
Windows). The wizard checks that as soon as the name is typed, says what
holds it, and asks again; Enter still means a private slot. The commit
repeats the check under the edit locks, so a name claimed in between
refuses the save with config.ErrCredentialKeyInUse instead of
overwriting it.

The staging journal records a keyed digest of each staged value, and
cleanup and crash recovery remove a slot only while the store still holds
that value, so a named slot another writer took over survives recovery.
Whether the config still references a slot is read from the parsed
config instead of a substring match, so API_KEY is no longer "referenced"
by DEEPSEEK_API_KEY and left behind as an orphan.

The prompt no longer shows a bracketed default it would not use.

Fixes #11002
2026-09-27 01:59:35 -07:00
xiexian a44319982f fix(sandbox): validate and narrow worktree metadata hints 2026-09-27 16:56:29 +08:00
YHH a8bea84e68 Merge pull request #10914 from esengine/fix/remote-prompt-card
fix(desktop): a remote Ask shows once and can be answered
2026-09-27 01:30:14 -07:00
YHH f457bbbd61 Merge pull request #10883 from esengine/fix/archive-recovered-copies
fix(desktop): archiving a recovered legacy conversation archives all its copies
2026-09-27 01:21:53 -07:00
YHH 3007c94084 Merge pull request #10997 from KHG420/codex/1x-history-reload-identity
fix(desktop): accept refreshed history after cache eviction
2026-09-27 01:12:16 -07:00
xiexian c09dbf56b9 fix(sandbox): include common Git metadata in retry hint 2026-09-27 16:08:19 +08:00
xiexian 26984605be fix(sandbox): guide worktree Git metadata write approval 2026-09-27 16:04:14 +08:00
esengine 85759d074c fix(desktop): archiving a recovered legacy conversation archives all its copies
A 1.38 write conflict saved one recovery copy per writer, each with the
recovered flag and the same parent in its metadata. The desktop catalog
runs metadata-only, which never folds them, so every copy is its own
sidebar row with the same title and the "legacy" and "recovered" badges.
Archiving a row archived exactly that copy and the next one took its
place, which reads as "cannot archive".

Archiving a recovered legacy source now also archives the other recovered
copies in its directory whose metadata names the same parent, each through
the same source archive path. Unrelated conversations are untouched, and
everything lands in the archive where it can be restored.

Reproduced on Windows with 1.39.1 and two copies written by
SaveConflictRecoveryBranch: one archive left the second row; with the fix
both rows go and both appear under Archived.
2026-09-27 00:59:53 -07:00
xiexian e47c32200c test(desktop): type history reload fixture for CI 2026-09-27 15:06:52 +08:00
xiexian 861a7b8d12 fix(desktop): accept refreshed older history after cache eviction 2026-09-27 14:47:40 +08:00
YHH 32107d7bb6 Merge pull request #10983 from esengine/fix/v1-release-notes-authors
feat(release-notes): credit pull request authors and issue fixers in rendered notes
2026-09-26 23:37:37 -07:00
esengine d5c9f2d739 feat(release-notes): credit pull request authors and issue fixers in rendered notes
Rendering now resolves every refs number through the GitHub GraphQL API.
A pull request gains "by @author". An issue gains "fixed in #M by @author"
for each merged pull request GitHub links as closing it (the ClosedEvent
closer and closedByPullRequestsReferences; mentions are not fixes); an
issue fixed by a direct push stays plain. Bot authors are never credited.
A number that names a discussion or nothing stays plain with a warning.

The closing Contributors / 贡献者 section lists exactly the credited
humans, once each, in first-appearance order. It replaces the old Thanks
section, which linked the catalog's contributors (github-actions[bot]
included) without mentioning them; that field no longer feeds the notes.

Lookups are cached per run, bounded in concurrency, and retried with
backoff on 5xx, rate limits and dropped connections. A failure that
persists, or an authorisation failure, renders the ref as its plain link
and fails the command with a release_credits.* code.

Because rendering now reads GitHub, the seal job renders the notes once
into the candidate payload (evidence/release-notes.md, recorded as
notes.renderedPath and bound to renderedSha256). Promotion publishes
those sealed bytes with no network call, so a rename, a newly linked fix
or an outage after sealing cannot change or block the release. Candidates
sealed before this carry no renderedPath and render offline as before.

Every job that renders, and every caller of a reusable workflow that
does, gains issues: read and pull-requests: read.
2026-09-26 23:24:50 -07:00
YHH 1a74c7450f Merge pull request #10979 from KHG420/codex/fix-1x-sidebar-loading
fix(desktop): show initial project loading state on 1.x
2026-09-26 22:59:20 -07:00
xiexian 947ce21896 chore(desktop): stay within project tree line budget 2026-09-27 13:30:40 +08:00
xiexian 872ad88677 fix(desktop): settle project shell loading after read failure 2026-09-27 13:21:07 +08:00
YHH 4512a84cbb Merge pull request #10977 from KHG420/codex/fix-reopened-session-index
fix(session): extend cached index after reopening writer
2026-09-26 22:17:13 -07:00
YHH 9424d402fb Merge pull request #10973 from esengine/fix/v1-10968-stream-generation
fix(transcript): stream live deltas for sessions on the legacy projection
2026-09-26 22:00:21 -07:00
esengine 2dfa1ca44c fix(transcript): stream live deltas for sessions on the legacy projection
A session opened from a legacy topic path follows its transcript through
the legacy projection (storageBackend "legacy"). There every turn event,
text and reasoning deltas included, takes the next ledger sequence, so
each follow change advanced commitSeq by one but carried no firstSeq.
The desktop follow client treats a change without firstSeq as a display
frame that must ride the current cut, rejected the very first change of
the turn as "transcript frame cut mismatch", dropped the subscription
and re-baselined once a second. No delta reached the reducer, so neither
thinking nor answer streamed and the reply appeared whole at turn end.

The stream_attempt commit on that path also asked for a reset, because
the legacy path never recorded a result sequence. Recording the message
event's sequence covers rounds with text or reasoning, but a tool-call-
only round and an interrupted round publish no message event at all.

Projection.Apply now publishes the sequence each envelope occupies as
the change's firstSeq. A commit settles on the recorded message sequence
or, when the round had no message event, on its own sequence: the agent
emits the commit only after the round's record is durably appended, so
that sequence is already covered. The entry is dropped at the commit so
the map stays bounded. ApplyFrame (the exclusive runtime path) is
unchanged.

Test: TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts
opens a legacy topic through StartTopicActivation on a real App with a
scripted streaming provider, follows through TranscriptFollowForTab and
applies the renderer's acceptance rules (mirroring
transcriptFollowClient.ts validate: coverage, sampling, resultKind,
records-based result identity with its 192 cap). Subtests run a
streamed reply, a tool-call-only round and a cancelled round, each
followed by a streamed reply.
- main-v2: all three fail with "frame_cut_mismatch at revision 3".
- message-sequence fix only: tool-call-only and cancelled rounds fail
  with "revision_gap" at the commit.
- this change: all pass, every delta delivered with no re-baseline.
Replaying captured follow responses through the real
TranscriptFollowClient accepted 0 deltas before and all of them after.

Fixes #10968
2026-09-26 21:32:08 -07:00
YHH e5795e5e0f chore: extract website and platform from product tree
Move the website and Cloudflare platform services to their dedicated protected branches. Keep main-v2 focused on the product and retain only the product-side release integration contract.
2026-09-27 04:29:09 +00:00
xiexian 86db9b4df3 fix(session): extend cached index after reopening writer 2026-09-27 12:22:14 +08:00
xiexian 8829515d5e style(desktop): keep project tree within lint budget 2026-09-27 12:19:16 +08:00
xiexian 8b2504fb6b fix(desktop): show loading state until first project tree snapshot 2026-09-27 12:04:12 +08:00
Adalberto Pereira 42503a4d36 fix(permission): ask unicode.IsSpace on both sides of the invariant / 检查与其不变量使用同一套空白判断
The check rejected " " and "\t" while the invariant test that justifies it
pinned " \t\r\n", so a tool name broken across lines was unmatchable but not
reported, and the two could drift further apart with neither failing. Both now
ask unicode.IsSpace, which also covers the non-breaking space a settings panel
can paste in.
2026-09-26 17:46:53 -03:00
Adalberto Pereira 598ecbf1b2 fix(permission): warn about a rule that names no tool / 提示匹配不到任何工具的权限规则
A permission rule is "ToolName" or "ToolName(glob)", but the rules people
write are bare shell commands: deny = ["rm", "git reset", "git push --force"].
ParseRule treats only an empty tool name as malformed, so those entries parse,
install, and then match nothing — the author believes a deny guards them and it
never fires. ParseRule's own doc comment states the intent ("so the caller can
warn rather than silently install a rule that matches nothing"); no caller did.

UnmatchableRules reports a rule whose tool name contains whitespace and renders
the Bash(...) rewrite that expresses the intent. Whitespace is an exact test
rather than a guess: a tool name is an identifier and never contains any, which
is now pinned by TestNoBuiltinToolNameHasWhitespace. That keeps the warning off
a plugin or MCP tool that is simply not registered yet — the per-run registry is
still filling when the policy is built, so membership cannot be tested there
without accusing rules that work.

The notice goes to the event sink rather than Config.LoadWarnings because only
the desktop reads those, and a rule typed into config.toml has to reach a CLI
user too.
2026-09-26 17:40:45 -03:00
esengine d730d8c8f8 fix(desktop): a remote Ask shows once and can be answered
#10865: in an SSH remote workspace an Ask showed twice and neither card
answered. The remote surface's card was disabled with "This card's exact
request identity is unavailable", and the footer's copy looked live but
its Submit went nowhere.

- RemoteSessionSurface required a runtimeEpoch on every prompt. A CLI
  serve never binds desktop routing metadata, so its prompts carry none;
  the serve and the kernel fence on the epoch only when they have one,
  and the desktop bridge forwards it unchanged. The check now stops at
  the host, session, generation and turn it can actually expect.
- The shell's decision footer also docked the remote prompt from shared
  state, but its answer path targets a local session a remote tab does
  not have, so it was cancelled as not ready. On a remote tab the footer
  leaves approvals, asks, MCP interactions and extension forms to the
  remote surface; shell decisions (clear context, close, conflicts) stay.

Reproduced on Windows with 1.39.1 against a Linux container over SSH
(same two cards, same error); with this build one card appears, both
questions answer and the turn continues.
2026-09-26 10:38:56 -07:00
498 changed files with 13320 additions and 37715 deletions

No files matched your search

-2
View File
@@ -10,9 +10,7 @@ on:
# conservative: when in doubt, run.
paths-ignore:
- 'docs/**'
- 'site/**'
- 'release-notes/**'
- 'workers/**'
- 'benchmarks/**'
- 'npm/**'
- '*.md'
+2 -33
View File
@@ -1636,16 +1636,13 @@ jobs:
-ignore 'label "windows-11-arm" is unknown' \
-ignore 'label "macos-15-intel" is unknown' \
.github/workflows/release-stable.yml \
.github/workflows/prepare-release-notes.yml \
.github/workflows/release-candidate.yml \
.github/workflows/release-candidate-verify.yml \
.github/workflows/release-promote.yml \
.github/workflows/release-verify.yml \
.github/workflows/release-site-recovery.yml \
.github/workflows/release.yml \
.github/workflows/release-npm.yml \
.github/workflows/release-desktop.yml \
.github/workflows/pages.yml \
.github/workflows/apple-notary-log.yml \
.github/workflows/macos-signing-check.yml \
.github/workflows/release-verify-issues.yml \
@@ -1669,14 +1666,14 @@ jobs:
node scripts/check-single-release-public-contract.mjs
# `lint` is a protected check name. Keep it as the fail-closed aggregate so
# Required check for the root-module and site jobs the per-OS `test` legs do
# required checks for root-module jobs the per-OS `test` legs do
# not cover. windows-control and sdk gate internally through RUN_STEPS and so
# always report a result: `success` here means "did not fail", not "validated
# this diff" — the same trade-off the required `test` legs already make. Do
# not add govulncheck: it sets continue-on-error, so needs.*.result is
# `success` even when its steps fail and the assertion would be a tautology.
root:
needs: [changes, windows-control, windows-isolated, sdk, site, coverage]
needs: [changes, windows-control, windows-isolated, sdk, coverage]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
steps:
@@ -1684,12 +1681,10 @@ jobs:
env:
CHANGES_RESULT: ${{ needs.changes.result }}
CODE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.code != 'false' }}
SITE_REQUIRED: ${{ (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false' }}
COVERAGE_REQUIRED: ${{ github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true' }}
CONTROL_RESULT: ${{ needs.windows-control.result }}
ISOLATED_RESULT: ${{ needs.windows-isolated.result }}
SDK_RESULT: ${{ needs.sdk.result }}
SITE_RESULT: ${{ needs.site.result }}
COVERAGE_RESULT: ${{ needs.coverage.result }}
run: |
test "$CHANGES_RESULT" = success
@@ -1697,7 +1692,6 @@ jobs:
test "$CONTROL_RESULT" = success
test "$SDK_RESULT" = success
test "$ISOLATED_RESULT" = "$(expected "$CODE_REQUIRED")"
test "$SITE_RESULT" = "$(expected "$SITE_REQUIRED")"
test "$COVERAGE_RESULT" = "$(expected "$COVERAGE_REQUIRED")"
# frontend unit coverage can run once in desktop-frontend without weakening
@@ -1731,31 +1725,6 @@ jobs:
test "$FRONTEND_RESULT" = skipped
fi
# The site/ auth client has security-sensitive redirect-validation logic
# (safeNext) covered by node:test unit tests. Those tests use only Node
# builtins, so no `npm install` is needed — run them directly on every PR so
# a regression in redirect validation fails the build instead of shipping.
# The notes_only term matches every other gated job here. Without it a
# release-notes-only push runs this job while the root aggregate expects the
# house-style skip, which would leave main-v2 permanently red. A pure
# release-notes/** diff cannot touch site/, so skipping it loses no coverage.
site:
needs: changes
if: ${{ !cancelled() && ((github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true') || needs.changes.outputs.site != 'false') }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: site
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version-file: .node-version
- name: test
run: npm test
govulncheck:
needs: changes
if: (github.event_name != 'pull_request' && needs.changes.outputs.notes_only != 'true')
@@ -1,46 +0,0 @@
name: Deploy accounts worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-accounts-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/accounts/pnpm-lock.yaml
- name: Deploy
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler deploy
- name: Sync RESEND_API_KEY to the worker
working-directory: workers/accounts
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
run: |
if [ -n "$RESEND_API_KEY" ]; then
printf '%s' "$RESEND_API_KEY" | npx wrangler secret put RESEND_API_KEY
else
echo "RESEND_API_KEY not set in repo secrets; skipping (worker stays in stub email mode)."
fi
-168
View File
@@ -1,168 +0,0 @@
name: Deploy crash worker
on:
workflow_dispatch:
inputs:
firebase_data_action:
description: Firebase crash history operation (migration actions do not deploy the Worker)
required: true
default: none
type: choice
options:
- none
- dry-run
- apply
- verify-only
confirmation:
description: Enter APPLY_FIREBASE_CRASH_DATA when selecting apply
required: false
default: ''
type: string
permissions:
contents: read
concurrency:
group: deploy-crash-worker
cancel-in-progress: false
jobs:
deploy:
if: github.event_name == 'push' || inputs.firebase_data_action == 'none'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Apply and verify diagnostics v2 D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:diagnostics-v2
- name: Apply and verify Firebase crash D1 migration
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:firebase-crash
- name: Apply dashboard performance indexes
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:dashboard-indexes
- name: Apply registry performance indexes and install rollup
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: npm run migrate:registry-indexes
- name: Sync Firebase crash secrets
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
run: |
configured=0
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 0 ] && [ "$configured" -ne 3 ]; then
echo "Firebase crash secrets must be either all configured or all absent."
exit 1
fi
node -e 'process.stdout.write(JSON.stringify({FIREBASE_DATABASE_URL:process.env.FIREBASE_DATABASE_URL||null,FIREBASE_CLIENT_EMAIL:process.env.FIREBASE_CLIENT_EMAIL||null,FIREBASE_PRIVATE_KEY:process.env.FIREBASE_PRIVATE_KEY||null}))' | npx wrangler secret bulk
- name: Deploy
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
npx wrangler deploy
# Mirrors the ALERT_WEBHOOK repo secret into the worker so the ingest
# sentinel can push alerts (see runIngestSentinel). Managed here so
# nobody needs local Cloudflare credentials; when the repo secret is
# unset the sentinel stays log-only.
- name: Sync alert webhook secret
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
ALERT_WEBHOOK: ${{ secrets.ALERT_WEBHOOK }}
run: |
if [ -z "$ALERT_WEBHOOK" ]; then
echo "ALERT_WEBHOOK repo secret not set; removing any existing worker secret."
printf '{"ALERT_WEBHOOK":null}' | npx wrangler secret bulk
exit 0
fi
printf '%s' "$ALERT_WEBHOOK" | npx wrangler secret put ALERT_WEBHOOK
migrate-firebase-data:
if: github.event_name == 'workflow_dispatch' && inputs.firebase_data_action != 'none'
runs-on: ubuntu-latest
environment: canary
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Require the protected production branch
run: |
if [ "$GITHUB_REF" != "refs/heads/main-v2" ]; then
echo "Firebase crash data migration must run from main-v2."
exit 1
fi
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: workers/crash-report/package-lock.json
- name: Validate
working-directory: workers/crash-report
run: |
npm ci
npm run typecheck
npm test
- name: Run Firebase crash history operation
working-directory: workers/crash-report
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
FIREBASE_DATABASE_URL: ${{ secrets.FIREBASE_DATABASE_URL }}
FIREBASE_CLIENT_EMAIL: ${{ secrets.FIREBASE_CLIENT_EMAIL }}
FIREBASE_PRIVATE_KEY: ${{ secrets.FIREBASE_PRIVATE_KEY }}
FIREBASE_DATA_ACTION: ${{ inputs.firebase_data_action }}
FIREBASE_DATA_CONFIRMATION: ${{ inputs.confirmation }}
run: |
configured=0
[ -n "$CLOUDFLARE_API_TOKEN" ] && configured=$((configured + 1))
[ -n "$FIREBASE_DATABASE_URL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_CLIENT_EMAIL" ] && configured=$((configured + 1))
[ -n "$FIREBASE_PRIVATE_KEY" ] && configured=$((configured + 1))
if [ "$configured" -ne 4 ]; then
echo "Cloudflare and Firebase migration secrets must all be configured."
exit 1
fi
case "$FIREBASE_DATA_ACTION" in
dry-run)
npm run migrate:firebase-data
;;
apply)
if [ "$FIREBASE_DATA_CONFIRMATION" != "APPLY_FIREBASE_CRASH_DATA" ]; then
echo "Apply requires the exact confirmation APPLY_FIREBASE_CRASH_DATA."
exit 1
fi
npm run migrate:firebase-data -- --apply
npm run migrate:firebase-data -- --verify-only
;;
verify-only)
npm run migrate:firebase-data -- --verify-only
;;
*)
echo "Unsupported Firebase crash data action."
exit 1
;;
esac
-36
View File
@@ -1,36 +0,0 @@
name: Deploy forum worker
on:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: deploy-forum-worker
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
with:
version: 10
run_install: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "24"
cache: pnpm
cache-dependency-path: workers/forum/pnpm-lock.yaml
- name: Deploy
working-directory: workers/forum
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
pnpm install --frozen-lockfile
pnpm test
pnpm typecheck
npx wrangler d1 execute reasonix-forum --remote --file=migrate-performance-indexes.sql
npx wrangler deploy
-76
View File
@@ -1,76 +0,0 @@
name: Deploy site
run-name: Deploy site ${{ inputs.release_version && format('v{0}', inputs.release_version) || github.sha }}${{ inputs.release_request && format(' [{0}]', inputs.release_request) || '' }}
on:
workflow_dispatch:
inputs:
release_version:
description: "Optional release version that owns this deployment"
required: false
default: ""
type: string
release_request:
description: "Release workflow correlation ID (optional)"
required: false
default: ""
type: string
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: false
jobs:
build:
if: github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: '22'
cache: npm
cache-dependency-path: site/package-lock.json
- name: Build
working-directory: site
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm ci
npm run build
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: site/dist
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
if: inputs.release_version != ''
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
if: inputs.release_version != ''
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
if: inputs.release_version != ''
with:
go-version-file: go.mod
cache: false
- name: Recheck release ownership immediately before deployment
if: inputs.release_version != ''
env:
VERSION: ${{ inputs.release_version }}
run: test "$(bash scripts/observe-release-site.sh "$VERSION" recover)" = true
- id: deploy
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5
-106
View File
@@ -1,106 +0,0 @@
name: Prepare release
on:
workflow_dispatch:
inputs:
version:
description: "Official version without a tag prefix (for example 1.20.0)"
required: true
type: string
permissions:
contents: write
pull-requests: write
jobs:
draft:
name: generate reviewable bilingual draft
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
fetch-depth: 0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- name: Select the review branch
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin main-v2
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
echo "::error::version must be MAJOR.MINOR.PATCH"
exit 1
fi
branch="release-notes/v${VERSION}"
if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then
git fetch origin "refs/heads/$branch:refs/remotes/origin/$branch"
git checkout -b "$branch" --track "origin/$branch"
source_sha="$(git merge-base origin/main-v2 HEAD)"
else
git checkout -b "$branch"
source_sha="$(git rev-parse HEAD)"
fi
git merge-base --is-ancestor "$source_sha" origin/main-v2
echo "RELEASE_NOTES_BRANCH=$branch" >> "$GITHUB_ENV"
echo "RELEASE_NOTES_SOURCE_SHA=$source_sha" >> "$GITHUB_ENV"
- name: Generate and validate release notes
env:
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DEEPSEEK_MODEL: deepseek-v4-pro
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
# The first preparation fixes the product range. Reruns update the
# draft without silently absorbing unrelated main-v2 merges.
node scripts/generate-release-notes.mjs --version "$VERSION" --to "$RELEASE_NOTES_SOURCE_SHA"
node scripts/release-notes.mjs validate
node --test scripts/release-notes.test.mjs
node scripts/release-notes.mjs render --version "$VERSION" --output /tmp/release-notes.md
{
echo "## Review draft for v${VERSION#v}"
echo
cat /tmp/release-notes.md
} >> "$GITHUB_STEP_SUMMARY"
- name: Open or update the review PR
env:
GH_TOKEN: ${{ github.token }}
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
git add release-notes/releases.json
if ! git diff --cached --quiet; then
git commit -m "docs(release): prepare v${VERSION#v} notes" \
-m "Summary:
Generate a bilingual, product-focused draft from merged pull request metadata. Reuse the selected release-bound PR when one is available.
Verification:
Validate the catalog, citations, bilingual fields, and rendered GitHub release notes before committing."
fi
git push -u origin "$RELEASE_NOTES_BRANCH"
if ! gh pr list --head "$RELEASE_NOTES_BRANCH" --state open --json number --jq 'length > 0' | grep -q true; then
if ! gh pr create \
--base main-v2 \
--head "$RELEASE_NOTES_BRANCH" \
--title "docs(release): 准备 v${VERSION#v} 中英更新日志" \
--body-file /tmp/release-notes.md; then
echo "::warning::GitHub Actions could not open the PR; the reviewed branch is preserved."
{
echo "## Manual PR handoff"
echo
echo '```sh'
echo "gh pr create --repo ${{ github.repository }} --base main-v2 --head $RELEASE_NOTES_BRANCH --fill"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
fi
fi
+18 -8
View File
@@ -194,6 +194,8 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -210,6 +212,7 @@ jobs:
env:
DOCS_BUILD_VERSION: v${{ needs.resolve.outputs.version }}
DOCS_SOURCE_REVISION: ${{ github.sha }}
GH_TOKEN: ${{ github.token }}
run: |
node scripts/release-notes.mjs validate
node scripts/release-notes.mjs render --version "$DOCS_BUILD_VERSION" --output /tmp/release-notes.md
@@ -259,6 +262,8 @@ jobs:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
uses: ./.github/workflows/release-desktop.yml
with:
channel: stable
@@ -285,6 +290,8 @@ jobs:
attestations: write
contents: read
id-token: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
@@ -338,6 +345,16 @@ jobs:
test -s payload/evidence/windows-amd64.json
test -s payload/evidence/windows-arm64.json
test -s payload/evidence/macos-universal-intel.json
# Rendered once and sealed: promotion publishes these bytes and never
# reads GitHub again, so a later rename or outage cannot change them.
- name: Render reviewed notes into the sealed payload
env:
GH_TOKEN: ${{ github.token }}
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output payload/evidence/release-notes.md
git worktree remove "$worktree"
- uses: actions/attest-build-provenance@977bb373ede98d70efdf65b84cb5f73e068dcc2a # v3
with:
subject-path: payload/**/*
@@ -359,13 +376,6 @@ jobs:
if-no-files-found: error
overwrite: true
retention-days: 90
- name: Render reviewed notes from the immutable product
run: |
git show "${{ github.sha }}:release-notes/releases.json" > /tmp/releases.json
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ github.sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ needs.resolve.outputs.version }}" --output /tmp/release-notes.md
git worktree remove "$worktree"
- name: Seal candidate record
env:
RELEASE_VERSION: ${{ needs.resolve.outputs.version }}
@@ -385,7 +395,7 @@ jobs:
RELEASE_ACCEPTANCE_JSON: >-
[{"kind":"windows-amd64","status":"passed","evidencePath":"evidence/windows-amd64.json"},{"kind":"windows-arm64","status":"passed","evidencePath":"evidence/windows-arm64.json"},{"kind":"macos-universal-intel","status":"passed","evidencePath":"evidence/macos-universal-intel.json"}]
run: |
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum /tmp/release-notes.md | awk '{print $1}')"
RELEASE_RENDERED_NOTES_SHA256="$(sha256sum payload/evidence/release-notes.md | awk '{print $1}')"
RELEASE_CREATED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
RELEASE_EXPIRES_AT="$(date -u -d '+30 days' +%Y-%m-%dT%H:%M:%SZ)"
export RELEASE_RENDERED_NOTES_SHA256 RELEASE_CREATED_AT RELEASE_EXPIRES_AT
+4 -9
View File
@@ -1084,6 +1084,8 @@ jobs:
permissions:
contents: write
actions: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# Approved orchestrators have already passed the matching GitHub environment.
# Direct prereleases and manual Stable recovery pass release-gate above. The
# Certum signing completes before either platform bundle reaches publication.
@@ -1200,6 +1202,8 @@ jobs:
# GitHub release as usual.
- name: Render reviewed release notes
if: ${{ !inputs.orchestrated && needs.resolve.outputs.channel != 'preview' }}
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ needs.resolve.outputs.notes_version }}" --output /tmp/release-notes.md
- name: Revalidate approved release ref
@@ -1292,7 +1296,6 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: write # gh release download + compatibility manifest upload
actions: write # dispatch pages.yml to re-bake the site version
# Stable keeps GitHub as a fallback when R2 is unavailable. Preview has no
# GitHub release, so it must fail closed before attempting publication.
if: ${{ always() && !cancelled() && needs.resolve.result == 'success' && needs.publish.result == 'success' && github.repository_owner == 'esengine' }}
@@ -1818,11 +1821,3 @@ jobs:
else
echo "CLI release $VERSION does not exist yet; release.yml will attach the compatibility latest.json when it publishes."
fi
# Stable release moved R2 latest/ — rebuild the site so its build-time baked
# version + JSON-LD follow (site.js's runtime .rxv refresh can't touch first paint / SEO).
- name: Refresh site to the new version
if: env.HAS_R2 == 'true' && needs.resolve.outputs.channel != 'preview' && needs.resolve.outputs.prerelease != 'true' && steps.mirror_r2.outputs.pointer_moved == 'true'
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+2 -9
View File
@@ -4,16 +4,9 @@ on:
pull_request:
paths:
- "release-notes/**"
- "scripts/generate-release-notes.mjs"
- "scripts/release-notes*.mjs"
- "scripts/release-credits*.mjs"
- "scripts/release-event.mjs"
- "site/src/pages/changelog/**"
- "site/src/components/ChangelogPage.astro"
- "site/src/data/releases.ts"
- "site/src/styles/changelog.css"
- "desktop/frontend/src/App.tsx"
- "desktop/frontend/src/components/SettingsPanel.tsx"
- "desktop/frontend/src/components/UpdateBanner.tsx"
permissions:
contents: read
@@ -27,4 +20,4 @@ jobs:
with:
node-version: "22"
- run: node scripts/release-notes.mjs validate
- run: node --test scripts/release-notes.test.mjs
- run: node --test scripts/release-notes.test.mjs scripts/release-credits.test.mjs
+16 -8
View File
@@ -23,6 +23,8 @@ permissions:
actions: write
attestations: read
contents: write
issues: read # granted to the nested release workflows, which can render notes
pull-requests: read
jobs:
preflight:
@@ -146,12 +148,19 @@ jobs:
echo "- Payload files: \`$(jq '.files | length' record/record.json)\`"
echo "- Native acceptance: \`$(jq -r '[.acceptance[] | select(.status == "passed") | .kind] | join(", ")' record/record.json)\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Render and bind reviewed release notes
# A candidate sealed with its notes publishes those bytes without touching
# the network. One sealed before that renders offline from its own source.
- name: Bind reviewed release notes
run: |
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
sealed="$(jq -r '.notes.renderedPath // empty' record/record.json)"
if [ -n "$sealed" ]; then
cp "payload/$sealed" release-notes.md
else
worktree="$(mktemp -d "$RUNNER_TEMP/reasonix-notes.XXXXXX")"
git worktree add --detach "$worktree" "${{ steps.record.outputs.source_sha }}"
node "$worktree/scripts/release-notes.mjs" render --version "v${{ steps.record.outputs.version }}" --output release-notes.md
git worktree remove "$worktree"
fi
test "$(sha256sum release-notes.md | awk '{print $1}')" = "$(jq -r .notes.renderedSha256 record/record.json)"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
@@ -306,7 +315,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher
@@ -326,7 +334,7 @@ jobs:
with:
go-version-file: go.mod
cache: false
- name: Reconcile public artifacts, owned Pages deployment, and durable ledger
- name: Reconcile public artifacts, release event, and durable ledger
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
@@ -334,7 +342,7 @@ jobs:
RELEASE_OPERATION: ${{ inputs.operation }}
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_LEDGER_OUTPUT: /tmp/publication-ledger.json
run: bash scripts/sync-release-site.sh
run: bash scripts/reconcile-release-publication.sh
- name: Upload publication ledger
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
-111
View File
@@ -1,111 +0,0 @@
name: Recover release site
run-name: Recover site v${{ inputs.version }}
on:
workflow_dispatch:
inputs:
version:
description: "Already-published stable version; no builds or package publication"
required: true
type: string
concurrency:
group: stable-release-publication
cancel-in-progress: false
permissions:
contents: read
jobs:
preflight:
if: github.repository == 'esengine/DeepSeek-Reasonix' && github.ref == 'refs/heads/main-v2' && github.ref_protected
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
source_sha: ${{ steps.identity.outputs.source_sha }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Validate executable contracts and public access before approval
env:
VERSION: ${{ inputs.version }}
run: |
bash scripts/validate-release-control-plane.sh
bash scripts/check-release-public-access.sh "$VERSION"
- name: Bind existing public tags to protected history
id: identity
env:
VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
[[ "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]
source_sha="$(git rev-parse "refs/tags/v$VERSION^{commit}")"
git merge-base --is-ancestor "$source_sha" origin/main-v2
bash scripts/release-candidate-tags.sh check recover "$VERSION" "$source_sha"
echo "source_sha=$source_sha" >> "$GITHUB_OUTPUT"
- name: Re-read public package identities before approval
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ steps.identity.outputs.source_sha }}
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/site-recovery-preflight.json
run: bash scripts/verify-stable-release-artifacts.sh
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: site-recovery-preflight-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/site-recovery-preflight.json
retention-days: 90
if-no-files-found: error
sync:
needs: preflight
environment: release
runs-on: ubuntu-latest
timeout-minutes: 45
permissions:
actions: write
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
ref: ${{ github.sha }}
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
with:
go-version-file: go.mod
cache: false
- name: Recover only the website and verify public state again
env:
GH_TOKEN: ${{ github.token }}
RELEASE_REPOSITORY: ${{ github.repository }}
RELEASE_VERSION: ${{ inputs.version }}
RELEASE_OPERATION: recover
RELEASE_EXPECTED_SHA: ${{ needs.preflight.outputs.source_sha }}
RELEASE_SITE_RECOVERY_ONLY: "true"
RELEASE_LEDGER_OUTPUT: ${{ runner.temp }}/publication-ledger.json
run: bash scripts/sync-release-site.sh
- name: Preserve successful or incomplete recovery evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-site-recovery-ledger-${{ inputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/publication-ledger.json
retention-days: 90
if-no-files-found: error
+6 -5
View File
@@ -55,6 +55,8 @@ concurrency:
permissions:
actions: write
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
jobs:
preflight:
@@ -63,6 +65,8 @@ jobs:
permissions:
actions: read
contents: read
issues: read # release-notes credits read PR and issue authors
pull-requests: read
outputs:
version: ${{ steps.release.outputs.version }}
cli_tag: ${{ steps.release.outputs.cli_tag }}
@@ -111,6 +115,8 @@ jobs:
bash scripts/validate-stable-candidate.sh "$RELEASE_VERSION" "$RELEASE_SHA"
bash scripts/verify-release-push-ci.sh "$RELEASE_SHA"
- name: Validate reviewed release notes
env:
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "${{ steps.release.outputs.cli_tag }}" --output /tmp/release-notes.md
# Recovery builds deliberately check out the immutable tagged candidate,
# which can predate its reviewed release-note entry. Carry the exact file
@@ -226,7 +232,6 @@ jobs:
if: ${{ always() && !cancelled() }}
runs-on: ubuntu-latest
permissions:
actions: write
contents: write
steps:
- name: Require every publisher to succeed
@@ -303,7 +308,3 @@ jobs:
else
gh release upload "$CLI_TAG" /tmp/release-event.json
fi
- name: Refresh public changelog
env:
GH_TOKEN: ${{ github.token }}
run: gh workflow run pages.yml --ref main-v2
+1 -1
View File
@@ -35,5 +35,5 @@ jobs:
RELEASE_OPERATION: recover
CLI_TAG: v${{ inputs.version }}
DESKTOP_TAG: desktop-v${{ inputs.version }}
VERIFY_HOMEPAGE: "true"
VERIFY_PUBLIC_POINTERS: "true"
run: bash scripts/verify-stable-release-artifacts.sh
+5
View File
@@ -212,6 +212,10 @@ jobs:
needs: [resolve, cache-guard]
if: ${{ always() && !cancelled() && needs.cache-guard.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: write
issues: read # release-notes credits read PR and issue authors
pull-requests: read
# The Stable caller has already passed the single GitHub release approval.
# Official standalone recovery passes release-gate above. This job therefore
# must not add a second GitHub environment approval.
@@ -274,6 +278,7 @@ jobs:
if: ${{ !inputs.orchestrated }}
env:
RELEASE_TAG: ${{ needs.resolve.outputs.notes_version }}
GH_TOKEN: ${{ github.token }}
run: node scripts/release-notes.mjs render --version "$RELEASE_TAG" --output /tmp/release-notes.md
- name: Revalidate approved release ref
env:
+3 -3
View File
@@ -56,9 +56,9 @@ Supported boundaries include:
- Sandbox behavior for built-in shell execution where the platform supports it.
- Secret handling for provider keys, bot credentials, OAuth tokens, plugin
headers, and credential-store fallback files.
- HTTP `serve` protections for the unauthenticated local server, including
localhost binding assumptions, JSON-only state-changing requests, and CORS
restrictions.
- HTTP `serve` protections, including the launch token every state-changing
request requires when authentication is off, localhost binding assumptions,
JSON-only state-changing requests, and CORS restrictions.
- Desktop and bot session isolation, including per-workspace session metadata
and configured bot allowlists.
- Updater, install, and release verification paths.
+15 -10
View File
@@ -6148,6 +6148,13 @@ func firstNonEmpty(values ...string) string {
}
func (a *App) ContextUsageForTab(tabID string) ContextInfo {
if a.isRemoteTab(tabID) {
used, window, ok := a.remoteContextSnapshot(tabID)
if ok {
return ContextInfo{Used: used, Window: window}
}
return ContextInfo{}
}
read := a.captureContextRead(tabID)
ctrl := read.ctrl
var info ContextInfo
@@ -7449,11 +7456,7 @@ func (a *App) mcpServersView() []ServerView {
}
func mcpEntryEnabled(p config.PluginEntry, workspace string) bool {
enabled, err := config.DefaultMCPActivationStore().IsEnabled(p, workspace)
if err != nil {
return p.ShouldAutoStart()
}
return enabled
return config.MCPServerEnabled(p, workspace)
}
func mcpRuntimeState(status string) string {
@@ -8517,6 +8520,9 @@ func (a *App) ReconnectMCPServer(name string) error {
if !found {
return fmt.Errorf("no configured MCP server named %q", name)
}
if err := config.RecordExplicitStart(entry, root); err != nil {
return err
}
controllers := a.mcpControllersSharingHost(host, name, ctrl)
for i := range controllers {
if controllers[i].ctrl == ctrl {
@@ -8673,6 +8679,9 @@ func (a *App) SetMCPServerTier(name, tier string) error {
}
a.bumpExtensionGeneration()
if tab != nil && ctrl != nil && !mcpConnected(ctrl, name) {
if err := config.RecordExplicitStart(updated, root); err != nil {
return err
}
if _, err := ctrl.ConnectMCPServer(updated); err != nil {
recordMCPFailure(ctrl, updated, err)
return nil
@@ -8707,7 +8716,7 @@ func (a *App) saveDesktopMCPServer(root string, entry config.PluginEntry) error
if err := ensureMCPServerDirectlyWritable(root, entry.Name); err != nil {
return err
}
_, err := config.UpsertPluginInSourceForRoot(root, entry)
_, err := config.UpsertPluginKeepingDecision(root, entry)
return err
}
@@ -9871,10 +9880,6 @@ func previewMediaKind(path string) (kind string, mime string) {
return "", ""
}
func (a *App) activeWorkspaceBase() (string, error) {
return workspaceBaseFromRoot(a.activeWorkspaceRoot())
}
func (a *App) workspaceTargetForTab(tabID string) (string, control.SessionAPI, bool) {
tabID = strings.TrimSpace(tabID)
a.mu.RLock()
+9 -33
View File
@@ -6909,39 +6909,6 @@ func (r *appendingDesktopRunner) Run(_ context.Context, input string) error {
return nil
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
func TestCapabilitiesIncludesInstalledPlugins(t *testing.T) {
isolateDesktopUserDirs(t)
reasonixHome := config.ReasonixHomeDir()
@@ -7006,6 +6973,7 @@ url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForTest(t, dir)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -8434,6 +8402,7 @@ command = "reasonix-missing-mcp-binary"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -8961,6 +8930,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9022,6 +8992,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9056,6 +9027,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
host := plugin.NewHost()
host.RecordFailure(plugin.Spec{Name: "figma", Type: "http", URL: "https://mcp.figma.com/mcp"}, errors.New("connect: 401 unauthorized"))
@@ -9230,6 +9202,7 @@ tier = "background"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9280,6 +9253,7 @@ name = "codegraph"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
reg := tool.NewRegistry()
reg.Add(desktopFakeTool{name: "mcp__codegraph__connect"})
@@ -9338,6 +9312,7 @@ tier = "lazy"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
@@ -9431,6 +9406,7 @@ tier = "eager"
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
+10 -2
View File
@@ -46,7 +46,11 @@ type DeliveryWorktreeOpenResult = IsolatedWorktreeOpenResult
// optional Git isolation path. A false result never disables writing itself;
// the cross-platform workspace writer lease remains the no-Git fallback.
func (a *App) IsolatedWorktreeAvailability(workspaceRoot string) worktree.Availability {
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Availability{Reason: err.Error()}
}
return inspectDeliveryWorktree(a.bootContext(), repo)
}
// CreateIsolatedWorktree creates a durable branch-backed worktree and opens it
@@ -61,7 +65,11 @@ func (a *App) CreateIsolatedWorktree(workspaceRoot string) (IsolatedWorktreeOpen
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
repo, err := a.workspaceRepoForRoot(workspaceRoot)
if err != nil {
return worktree.Result{}, err
}
return createDeliveryWorktree(a.bootContext(), repo, config.DeliveryWorktreeDir())
}()
if err != nil {
return IsolatedWorktreeOpenResult{}, err
+5 -2
View File
@@ -7,13 +7,15 @@ import (
"testing"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
original := inspectDeliveryWorktree
t.Cleanup(func() { inspectDeliveryWorktree = original })
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := filepath.Base(repo.Dir)
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
}
got := NewApp().DeliveryWorktreeAvailability("project")
@@ -35,7 +37,8 @@ func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
original := createDeliveryWorktree
t.Cleanup(func() { createDeliveryWorktree = original })
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := filepath.Base(sourceRepo.Dir)
if source != "source-project" {
t.Fatalf("source = %q", source)
}
+3 -2
View File
@@ -66,7 +66,8 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
if scope != "project" || strings.TrimSpace(srcRoot) == "" {
result.FallbackToShared = true
} else {
avail := inspectDeliveryWorktree(a.bootContext(), srcRoot)
srcRepo, _ := workspaceRepo(srcRoot, ctrl)
avail := inspectDeliveryWorktree(a.bootContext(), srcRepo)
if !avail.Available {
result.FallbackToShared = true
} else if avail.SourceDirty {
@@ -80,7 +81,7 @@ func (a *App) forkForTabWithOptions(tabID string, turn int, isolateWorkspace boo
return worktree.Result{}, err
}
defer releaseAdmission()
return createDeliveryWorktree(a.bootContext(), srcRoot, config.DeliveryWorktreeDir())
return createDeliveryWorktree(a.bootContext(), srcRepo, config.DeliveryWorktreeDir())
}()
if createErr != nil {
return ForkWorktreeResultView{}, fmt.Errorf("create isolated fork worktree: %w", createErr)
+16 -10
View File
@@ -8,6 +8,7 @@ import (
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/gitcmd"
"reasonix/internal/worktree"
)
@@ -28,10 +29,12 @@ func TestForkWorktreeForTabCreatesIsolatedWorkspace(t *testing.T) {
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, Branch: "main"}
}
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
createDeliveryWorktree = func(_ context.Context, sourceRepo gitcmd.Repo, gotManaged string) (worktree.Result, error) {
source := sourceRepo.Dir
return worktree.Result{
WorkspaceRoot: isolatedRoot,
WorktreeRoot: filepath.Dir(isolatedRoot),
@@ -90,11 +93,12 @@ func TestForkWorktreeForTabRefusesDirtySourceWithoutMutation(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root, SourceDirty: true}
}
createCalls := 0
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
createCalls++
return worktree.Result{}, nil
}
@@ -125,10 +129,10 @@ func TestForkWorktreeForTabFallsBackToSharedFork(t *testing.T) {
inspectDeliveryWorktree = origInspect
createDeliveryWorktree = origCreate
})
inspectDeliveryWorktree = func(context.Context, string) worktree.Availability {
inspectDeliveryWorktree = func(context.Context, gitcmd.Repo) worktree.Availability {
return worktree.Availability{Reason: "not a repository"}
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
t.Fatal("fallback must not create a worktree")
return worktree.Result{}, nil
}
@@ -164,7 +168,8 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
createDeliveryWorktree = origCreate
rollbackDeliveryWorktree = origRollback
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
created := worktree.Result{
@@ -174,7 +179,7 @@ func TestForkWorktreeForTabRollsBackUnusedCreation(t *testing.T) {
Branch: "reasonix/delivery-test",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
@@ -211,7 +216,8 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
rollbackDeliveryWorktree = origRollback
forkTabBeforePublishHookForTest.Store(nil)
})
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
inspectDeliveryWorktree = func(_ context.Context, repo gitcmd.Repo) worktree.Availability {
root := repo.Dir
return worktree.Availability{Available: true, RepoRoot: root}
}
worktreeRoot := filepath.Join(t.TempDir(), "worktree")
@@ -226,7 +232,7 @@ func TestForkWorktreeForTabPreservesReferencedWorkspaceWhenSourceCloses(t *testi
Branch: "reasonix/delivery-preserved",
Head: "deadbeef",
}
createDeliveryWorktree = func(context.Context, string, string) (worktree.Result, error) {
createDeliveryWorktree = func(context.Context, gitcmd.Repo, string) (worktree.Result, error) {
return created, nil
}
rollbackCalls := 0
+5 -5
View File
@@ -32,7 +32,7 @@
"test:code-browser": "node bench/code-theme.mjs",
"test:dock-responsive-browser": "node bench/responsive-dock.mjs",
"test:diagnostics": "tsx src/__tests__/diagnostics-settings.test.tsx",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts",
"test:transcript": "tsx src/__tests__/canonical-user-display.test.ts && tsx src/__tests__/steer-transcript.test.ts && tsx src/__tests__/chat-tool-presentation.test.ts && tsx src/__tests__/chat-view-source.test.ts && tsx src/__tests__/maintenance-lifecycle.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/maintenance-card.test.tsx && tsx src/__tests__/presented-files.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/presented-files-ui.test.tsx && tsx src/__tests__/chat-content-loader.test.ts && tsx src/__tests__/chat-details-race.test.tsx && tsx src/__tests__/tool-payload-preview.test.ts && tsx src/__tests__/markdown-natural-flow.test.tsx && tsx src/__tests__/chat-natural-flow.test.tsx && tsx src/__tests__/chat-scroll-controller.test.ts && tsx src/__tests__/markdown-pipeline.test.tsx && tsx src/__tests__/markdown-worker-client.test.ts && tsx src/__tests__/transcript-store.test.ts && tsx src/__tests__/transcript-memory-pressure.test.ts && tsx src/__tests__/transcript-tool-association.test.ts && tsx src/__tests__/transcript-follow-client.test.ts && tsx src/__tests__/transcript-session-follower.test.ts && tsx src/__tests__/transcript-scroll-diagnostics.test.ts && tsx src/__tests__/session-export-routing.test.tsx && tsx src/__tests__/session-export-operation.test.ts && tsx src/__tests__/typography-overflow-contract.test.ts && tsx src/__tests__/nested-scroll-handoff.test.ts && tsx src/__tests__/transcript-outline-store.test.ts && tsx src/__tests__/chat-turn-jump.test.ts && node --import ./scripts/css-stub-register.mjs --import tsx src/__tests__/chat-turn-outline-jump.test.tsx && tsx src/__tests__/native-transcript-history.test.ts && tsx src/__tests__/history-read-binding.test.ts && tsx src/__tests__/history-reload-identity.test.ts",
"test:session-export-browser": "node bench/session-export.mjs",
"test:session-loading-browser": "node bench/session-loading.mjs && node bench/draft-recovery.mjs && node bench/manual-creation.mjs && node bench/manual-creation-recovery.mjs",
"test:draft-recovery-browser": "node bench/draft-recovery.mjs",
@@ -72,14 +72,14 @@
"@dnd-kit/sortable": "10.0.0",
"@dnd-kit/utilities": "3.2.2",
"@modelcontextprotocol/ext-apps": "1.7.5",
"@tanstack/react-virtual": "^3.14.10",
"@tanstack/react-virtual": "^3.14.13",
"@xterm/addon-fit": "0.11.0",
"@xterm/xterm": "6.0.0",
"anser": "^2.3.5",
"hast-util-to-jsx-runtime": "2.3.6",
"highlight.js": "^11.12.0",
"html-url-attributes": "3.0.1",
"katex": "^0.18.6",
"katex": "^0.18.9",
"lucide-react": "^1.41.0",
"mermaid": "^11.17.2",
"qrcode.react": "^4.2.0",
@@ -103,7 +103,7 @@
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7",
"@typescript-eslint/parser": "^8.69.0",
"@typescript-eslint/parser": "^8.70.1",
"@vitejs/plugin-react": "^6.1.1",
"decode-named-character-reference": "^1.3.0",
"eslint": "^10.10.0",
@@ -113,7 +113,7 @@
"mdast-util-math": "^3.0.0",
"playwright": "^1.63.0",
"terser": "^5.51.2",
"tsx": "^4.23.13",
"tsx": "^4.23.15",
"typescript": "^6.0.3",
"vite": "^8.2.2"
},
@@ -25,7 +25,7 @@ const background = [{ id: "local-runtime" }] as unknown as BackgroundRuntimeView
const view = projectConversation({ local, remote, tab, activeTabId: tab.id, backgroundRuntimes: background, connectingLabel: "connecting" });
assert.equal(view.runtime, remote.transcript, "projection shares the canonical state and message arrays");
assert.equal(view.context.items, remote.transcript.items);
assert.equal(view.context.tabId, undefined, "remote context cannot fetch local telemetry");
assert.equal(view.context.tabId, tab.id, "remote context reads the selected remote tab");
assert.equal(view.composer.modelLabel, "remote-model");
assert.equal(view.composer.cwd, "remote-cwd");
assert.equal(view.composer.turnTokens, 7);
@@ -0,0 +1,13 @@
// Run: tsx src/__tests__/decision-surface-remote.test.ts
import assert from "node:assert/strict";
import { projectDecisionSurface } from "../app-runtime/decisionSurfaceProjection";
const base = { approval: undefined, ask: undefined, mcpInteraction: undefined, extensionForm: undefined, workspaceConflict: null, pendingClose: null, clearContextPending: false } as unknown as Parameters<typeof projectDecisionSurface>[0];
const ask = { id: "1", questions: [{ id: "q1", prompt: "?", options: [{ label: "a" }, { label: "b" }] }] } as unknown as Parameters<typeof projectDecisionSurface>[0]["ask"];
const approval = { id: "call-1", tool: "bash" } as unknown as Parameters<typeof projectDecisionSurface>[0]["approval"];
assert.equal(projectDecisionSurface({ ...base, ask }), "ask", "a local ask docks in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true }), null, "a remote ask belongs to the remote surface, not a second footer card");
assert.equal(projectDecisionSurface({ ...base, approval, remote: true }), null, "a remote approval is not duplicated in the footer");
assert.equal(projectDecisionSurface({ ...base, ask, remote: true, clearContextPending: true }), "clear_context", "shell decisions still reach the footer on a remote tab");
console.log("decision-surface-remote: 4 passed");
@@ -0,0 +1,71 @@
import assert from "node:assert/strict";
import { JSDOM } from "jsdom";
import { installDesktopHostStub } from "./desktopHostStub";
import { getTranscriptStore } from "../lib/transcriptStore";
import { loadHistoryWindow, type HistoryWindowAction } from "../lib/historyWindowController";
import { initialState, reducer, type State } from "../lib/useController";
const dom = new JSDOM("", { url: "http://localhost/" });
globalThis.window = dom.window as unknown as Window & typeof globalThis;
let revision = 1;
const stub = installDesktopHostStub({
SessionHistoryWindowForTab: async () => ({
status: "ready",
snapshotSequence: revision,
generation: `generation-${revision}`,
totalTurns: 30,
hasOlder: true,
olderCursor: "older-cursor",
messages: [{ messageId: `user-${revision}`, position: 30, version: 1, role: "user", eventSequence: 30,
visibleTurn: 30, preview: `revision ${revision}`, inline: { id: `user-${revision}`, role: "user", content: `revision ${revision}` } }],
}),
});
try {
const store = getTranscriptStore();
for (const protocol of [1, 2] as const) {
revision = 1;
const tabId = `history-reload-tab-${protocol}`;
const sessionPath = `/fixture/history-reload-${protocol}.jsonl`;
const initial = await store.loadLatest(tabId, sessionPath);
assert.equal(initial?.revision, 1);
store.evictTab(tabId);
revision = 2;
let state: State = {
...initialState,
transcriptProtocol: protocol,
historyStartTurn: initial!.startTurn,
historyTotalTurns: initial!.totalTurns,
historyHasOlder: true,
historyHasNewer: false,
historyOlderLoading: false,
historyNewerLoading: false,
historyRevision: 1,
historyDigest: "generation-1",
running: false,
meta: { label: "fixture", ready: true, eventChannel: "fixture", cwd: "/fixture", sessionPath,
sessionRevision: 1, sessionDigest: "generation-1" },
};
const actions: HistoryWindowAction[] = [];
const outcome = await loadHistoryWindow({
tabId, direction: "older", trigger: "retry", state, requestSeq: 1,
isCurrent: () => true,
currentState: () => state,
dispatch: action => {
actions.push(action);
state = reducer(state, action);
},
});
assert.equal(outcome, "loaded", `protocol ${protocol}: refreshed page should replace an evicted old window: ${JSON.stringify(actions)}`);
assert.equal(actions[actions.length - 1]?.type, protocol === 2 ? "transcript_records" : "history_replace");
assert.equal(actions.find(action => action.type === "history_older_error"), undefined);
assert.equal(state.historyRevision, 2);
assert.equal(state.historyDigest, "generation-2");
assert.equal(state.historyOlderLoading, false);
assert.ok(state.items.some(item => item.kind === "user" && item.text === "revision 2"));
}
} finally {
stub.uninstall();
dom.window.close();
}
@@ -51,7 +51,7 @@ type ControllerState = Parameters<typeof reducer>[0];
const appSource = readFileSync(new URL("../AppRuntime.tsx", import.meta.url), "utf8");
const sessionCompositionSource = readFileSync(new URL("../app-runtime/useAppSessionComposition.ts", import.meta.url), "utf8");
ok(
/\[clearContextPending, pendingClose, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
/\[clearContextPending, pendingClose, remoteSurfaceActive, state\.approval, state\.ask, state\.extensionForm, state\.mcpInteraction, workspaceConflict\]/.test(sessionCompositionSource),
"App decision surface recomputes when an MCP interaction arrives",
);
@@ -0,0 +1,125 @@
// Run: tsx src/__tests__/project-tree-organization-group-drop.test.tsx
import { JSDOM } from "jsdom";
import React from "react";
import { act } from "react";
import { createRoot } from "react-dom/client";
import { useProjectTreeOrganization } from "../components/ProjectTreeOrganization";
import type { ProjectNode, ProjectTreeOrganizationBindings, SessionGroup } from "../lib/types";
import type { SessionOrganizationMutation, SessionOrganizationSnapshot } from "../generated/desktopContract.generated";
import { ToastProvider } from "../lib/toast";
import { LocaleProvider } from "../lib/i18n";
let passed = 0;
let failed = 0;
function ok(value: boolean, label: string) {
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
if (value) passed += 1; else failed += 1;
}
function installDom() {
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
pretendToBeVisual: true,
url: "http://localhost/",
});
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
globalThis.window = dom.window as unknown as Window & typeof globalThis;
globalThis.document = dom.window.document;
globalThis.Node = dom.window.Node;
globalThis.Element = dom.window.Element;
globalThis.HTMLElement = dom.window.HTMLElement;
globalThis.Event = dom.window.Event;
globalThis.MouseEvent = dom.window.MouseEvent;
return dom;
}
function topic(id: string): ProjectNode {
return { key: `topic-${id}`, kind: "topic", label: id, root: "/repo", topicId: id, session: { hostId: "local", sessionId: id } } as ProjectNode;
}
const inside = topic("inside");
const sibling = topic("sibling");
const outside = topic("outside");
const loose = topic("loose");
const folder: ProjectNode = { key: "project-/repo", kind: "project", label: "Repo", root: "/repo", children: [inside, sibling, outside, loose] };
const groups: SessionGroup[] = [{ id: "g1", title: "Group", sessionKeys: ["ref\0local\0inside", "ref\0local\0sibling"] }];
function Harness({ bindings }: { bindings: ProjectTreeOrganizationBindings }) {
const organization = useProjectTreeOrganization({ tree: [folder], refresh: async () => {}, bindings });
const loaded = organization.groupsFor(folder).length > 0;
return <>
<output id="loaded">{loaded ? "yes" : "no"}</output>
{folder.children!.map((node) => {
const row = organization.topicRow(node, false);
return <div key={node.key} id={`row-${node.topicId}`} {...row.props}>{node.label}</div>;
})}
</>;
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
function fireDrag(type: string, target: Element, data: Map<string, string>) {
const event = new window.Event(type, { bubbles: true, cancelable: true });
const dataTransfer = {
setData: (format: string, value: string) => { data.set(format, value); },
getData: (format: string) => data.get(format) ?? "",
effectAllowed: "all",
dropEffect: "none",
};
Object.defineProperty(event, "dataTransfer", { value: dataTransfer });
Object.defineProperty(event, "clientY", { value: 10 });
target.dispatchEvent(event);
}
async function dropOnto(draggedID: string, targetID: string): Promise<SessionOrganizationMutation[]> {
const dom = installDom();
const mutations: SessionOrganizationMutation[] = [];
const snapshot = (): SessionOrganizationSnapshot => ({ groups: structuredClone(groups), revision: 1, applied: true, order: [], manualOrderEnabled: false });
const bindings: ProjectTreeOrganizationBindings = {
ReorderTopics: async () => {},
ListProjectGroups: async () => [],
SaveSessionGroups: async () => {},
GetSessionOrganization: async () => snapshot(),
UpdateSessionOrganization: async (_workspace, _revision, mutation) => { mutations.push(mutation); return snapshot(); },
};
const root = createRoot(document.getElementById("root")!);
await act(async () => {
root.render(<LocaleProvider><ToastProvider><Harness bindings={bindings} /></ToastProvider></LocaleProvider>);
await flush();
});
for (let attempt = 0; attempt < 30 && document.getElementById("loaded")?.textContent !== "yes"; attempt += 1) await act(flush);
const data = new Map<string, string>();
await act(async () => { fireDrag("dragstart", document.getElementById(`row-${draggedID}`)!, data); await flush(); });
await act(async () => { fireDrag("dragover", document.getElementById(`row-${targetID}`)!, data); await flush(); });
await act(async () => { fireDrag("drop", document.getElementById(`row-${targetID}`)!, data); await flush(); });
for (let attempt = 0; attempt < 10; attempt += 1) await act(flush);
await act(async () => root.unmount());
dom.window.close();
return mutations;
}
console.log("\nproject tree drop onto a group member row");
{
const mutations = await dropOnto("outside", "inside");
ok(mutations.length === 1 && mutations[0].kind === "set-group" && mutations[0].groupId === "g1",
`dropping a non-member on a member row adds it to that group (got ${JSON.stringify(mutations.map((m) => [m.kind, m.groupId ?? ""]))})`);
}
{
const mutations = await dropOnto("sibling", "inside");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping a member on another member of its group reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
{
const mutations = await dropOnto("outside", "loose");
ok(mutations.length === 1 && mutations[0].kind === "move",
`dropping on an ungrouped row reorders (got ${JSON.stringify(mutations.map((m) => m.kind))})`);
}
process.stdout.write(`\nproject-tree-organization-group-drop: ${passed} passed, ${failed} failed\n`);
if (failed > 0) process.exit(1);
@@ -25,8 +25,9 @@ const { ToastProvider } = await import("../lib/toast");
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(done => { resolve = done; });
return { promise, resolve };
let reject!: (reason: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
const pending: ReturnType<typeof deferred<ProjectTreeSnapshot>>[] = [];
@@ -78,9 +79,21 @@ try {
try {
await render(0);
assert.equal(pending.length, 1, "mount starts one shell read");
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/,
"an unanswered shell read must say it is loading");
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"pending shell read must not claim there are no projects");
if (scenario.name === "addition") {
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null,
"fast shell reads must not flash a skeleton");
await act(async () => mock.timers.tick(250));
assert.ok(container.querySelector(".project-tree__empty-state .project-tree__skeleton"),
"a delayed shell read shows a skeleton");
}
await act(async () => pending[0].resolve(snapshot(scenario.before)));
await flush();
assert.deepEqual(labels(), scenario.before.map(item => item.label));
assert.equal(container.querySelector(".project-tree__empty-state .project-tree__skeleton"), null);
// A read started before the mutation retains the old shell contents.
// Its scalar revision may equal the post-mutation read: this fixture
@@ -114,6 +127,44 @@ try {
await act(async () => root.unmount());
}
}
pending.length = 0;
localStorage.clear();
const emptyRoot = createRoot(container);
try {
await act(async () => emptyRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.doesNotMatch(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/);
await act(async () => pending[0].resolve(snapshot([])));
await flush();
assert.match(container.textContent ?? "", /No projects yet|还没有项目|還沒有專案/,
"an empty answer restores the true empty state");
} finally {
await act(async () => emptyRoot.unmount());
}
pending.length = 0;
localStorage.clear();
const failedRoot = createRoot(container);
try {
await act(async () => failedRoot.render(<LocaleProvider><ToastProvider><ProjectTree
onOpenTopic={noop} onAddProject={addProject}
/></ToastProvider></LocaleProvider>));
await flush();
assert.match(container.querySelector(".project-tree__empty-state")?.textContent ?? "", /读取|讀取|Reading/);
await act(async () => pending[0].reject(new Error("snapshot unavailable")));
await flush();
assert.doesNotMatch(container.textContent ?? "", /读取项目|讀取專案|Reading projects/,
"a failed first shell read must settle the loading state");
assert.match(container.textContent ?? "", /Add new project|添加新项目|新增專案/,
"the empty-state project action must remain available after a failed read");
assert.match(container.textContent ?? "", /Remote connection|远程连接|遠端連線/,
"the empty-state remote action must remain available after a failed read");
} finally {
await act(async () => failedRoot.unmount());
}
} finally {
mock.timers.reset();
dom.window.close();
@@ -391,6 +391,22 @@ await act(async () => {
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && document.body.textContent?.includes("remote answer failed") === true && document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.disabled === false, "a failed remote Ask answer preserves the card, surfaces the error, and re-enables retry"); failAnswer = false; await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); }); ok(tape.filter((entry) => entry.startsWith("answer:tab-remote-1:ask-7:")).length === 2 && !document.querySelector(".prompt-shelf--ask"), "a successful remote Ask retry resubmits the complete answer and clears the card");
}
// A CLI serve never binds desktop routing metadata, so its prompts carry no
// runtimeEpoch; the serve fences on the epoch only when it has one.
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", ask: { id: "ask-serve", questions: [{ id: "q1", prompt: "Ship it?", options: [{ label: "ship" }, { label: "wait" }] }] } });
await flush();
});
{
ok(Boolean(document.querySelector(".prompt-shelf--ask")) && !document.body.textContent?.includes("exact request identity is unavailable"), "an ask without a runtime epoch is answerable, not refused as unidentified");
await act(async () => {
[...document.querySelectorAll<HTMLButtonElement>("button")].find((b) => b.textContent?.trim() === "ship")?.click();
await flush();
});
await act(async () => { document.querySelector<HTMLButtonElement>(".prompt-shelf--ask .decision-confirm-bar__confirm")?.click(); await flush(); });
ok(tape.some((entry) => entry.startsWith("answer:tab-remote-1:ask-serve:")) && !document.querySelector(".prompt-shelf--ask"), "the answer reaches the serve and the card clears");
}
await act(async () => {
__emitMockRemoteTab("tab-remote-1", "event", { kind: "ask_request", turnId: "turn-main", runtimeEpoch: "runtime-main", ask: { id: "ask-custom", questions: [{ id: "q-custom", prompt: "Where?", options: [{ label: "staging" }] }] } });
await flush();
@@ -0,0 +1,49 @@
import assert from "node:assert/strict";
import { setAttentionPreference, setSuccessPreference } from "../lib/sound";
import { createRuntimeNotifications } from "../lib/runtimeNotifications";
import { t } from "../lib/i18n";
const stored = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem(key: string) { return stored.get(key) ?? null; },
setItem(key: string, value: string) { stored.set(key, value); },
removeItem(key: string) { stored.delete(key); },
clear() { stored.clear(); },
},
});
let contexts = 0;
class FakeAudioContext {
destination = {};
constructor() { contexts++; }
createOscillator() { return { type: "sine", frequency: { setValueAtTime() {} }, connect() {}, start() {}, stop() {} }; }
createGain() {
return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} };
}
close() {}
}
Object.defineProperty(globalThis, "AudioContext", { configurable: true, value: FakeAudioContext });
const owner = createRuntimeNotifications(() => ({ activeTabId: "A", t, showToast() {} }));
function chimesFor(event: { err?: string; outcome?: string }, only: "success" | "attention"): number {
setSuccessPreference(only === "success" ? "synth" : "off");
setAttentionPreference(only === "attention" ? "synth" : "off");
const before = contexts;
owner.accept({ event: { kind: "turn_done", tabId: "A", ...event } });
return contexts - before;
}
const readiness = { err: "delivery checks are incomplete", outcome: "final_readiness" };
assert.equal(chimesFor(readiness, "attention"), 1, "a turn that stopped for delivery checks must alert the user");
assert.equal(chimesFor(readiness, "success"), 0, "a turn that stopped for delivery checks is not a success");
for (const outcome of ["recovery_paused", "incomplete_read", "completion_uncertain"]) {
assert.equal(chimesFor({ err: "paused", outcome }, "attention"), 1, `${outcome} waits on the user`);
}
assert.equal(chimesFor({}, "success"), 1, "a clean finish keeps the success chime");
assert.equal(chimesFor({}, "attention"), 0, "a clean finish does not ring the attention chime");
assert.equal(chimesFor({ err: "provider failed" }, "success") + chimesFor({ err: "provider failed" }, "attention"), 0,
"an ordinary failure stays silent");
owner.dispose();
console.log("runtime notifications: paused turn outcomes ring the attention chime");
@@ -87,7 +87,7 @@ export function projectConversation({ local, remote, tab, activeTabId, backgroun
cacheMissTokens: runtime.usage?.cacheMissTokens, balance: runtime.balance,
},
context: {
tabId: remote ? undefined : activeTabId,
tabId: activeTabId,
items: runtime.items, context: runtime.context, usage: runtime.usage,
sessionTokens: runtime.sessionTokens, sessionCost: runtime.sessionCost,
sessionCurrency: runtime.sessionCurrency, turnTokens: runtime.turnTotalTokens,
@@ -19,6 +19,26 @@ export function projectDecisionSurface(input: {
workspaceConflict: WorkspaceConflictView | null;
pendingClose: PendingClose;
clearContextPending: boolean;
remote?: boolean;
}): AppDecisionSurfaceKind | null {
// A remote session's prompts and forms are answered by RemoteSessionSurface,
// the one owner of the serve's exact prompt route; a footer copy would be a
// second card whose answer has no local session to reach.
if (!input.remote) {
const prompt = projectPromptSurface(input);
if (prompt) return prompt;
}
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
function projectPromptSurface(input: {
approval: State["approval"];
ask: State["ask"];
mcpInteraction: State["mcpInteraction"];
extensionForm: State["extensionForm"];
}): AppDecisionSurfaceKind | null {
if (input.approval) {
return input.approval.tool === "exit_plan_mode" ? "plan_approval" : "tool_approval";
@@ -26,8 +46,5 @@ export function projectDecisionSurface(input: {
if (input.ask) return "ask";
if (input.mcpInteraction) return "mcp_interaction";
if (input.extensionForm) return "extension_form";
if (input.workspaceConflict) return "workspace_conflict";
if (input.pendingClose) return "close_active";
if (input.clearContextPending) return "clear_context";
return null;
}
@@ -345,8 +345,8 @@ export function useAppSessionComposition(input: AppSessionCompositionInput) {
const decisionSurface = useMemo((): AppDecisionSurfaceKind | null => projectDecisionSurface({
approval: state.approval, ask: state.ask, mcpInteraction: state.mcpInteraction, extensionForm: state.extensionForm,
workspaceConflict, pendingClose, clearContextPending,
}), [clearContextPending, pendingClose, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
workspaceConflict, pendingClose, clearContextPending, remote: remoteSurfaceActive,
}), [clearContextPending, pendingClose, remoteSurfaceActive, state.approval, state.ask, state.extensionForm, state.mcpInteraction, workspaceConflict]);
const visibleDecisionSurface = decisionSurface;
// Navigation used to hide the entire composer until the controller/runtime
// activation ticket and the transcript paint ticket both settled. That made
@@ -31,7 +31,7 @@ export function useRuntimeNotifications(activeTabId: string | undefined) {
pending.current = [];
};
}, [readPorts]);
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string }) => {
const handleNotification = useCommittedCommand((event: AttentionChimeEvent & { err?: string; outcome?: string }) => {
if (event.kind === "ask_request" || event.kind === "approval_request" || event.kind === "turn_done") accept({ event });
});
const resetLegacyAttention = useCommittedCommand((resetTabId?: string) => accept({ resetTabId }));
@@ -45,7 +45,9 @@ export function SidebarRegion(props: SidebarRegionProps) {
</button>
</div>
<section className="sidebar__section sidebar__section--projects">
<Suspense fallback={null}><ProjectTree {...props.projectTree} /></Suspense>
<Suspense fallback={<div className="project-tree__empty project-tree__empty--subtle" role="status">{t("projectTree.loadingProjects")}</div>}>
<ProjectTree {...props.projectTree} />
</Suspense>
</section>
<nav className="sidebar__nav sidebar__nav--footer">
<div className="sidebar__utility-row" aria-label={t("sidebar.utilityActions")}>
@@ -128,6 +128,7 @@ export function ProjectTree({
const compactTopics = variant === "workbench";
const creationTopics = variant === "creation";
const [tree, setTree] = useState<ProjectNode[]>([]);
const [shellStage, setShellStage] = useState<"loading" | "slow" | "ready">("loading");
const treeRef = useRef<ProjectNode[]>([]);
const latestRevisionRef = useRef(0);
const shellRequestRef = useRef(0);
@@ -490,10 +491,11 @@ export function ProjectTree({
})));
await reloadRequestedProjects(projects);
} catch (err) {
// A shell snapshot is metadata-only. If it fails, the resident folder
// identity can still drive the requested canonical topic reload.
// A failed shell read can still reload topics from resident folders.
if (request === shellRequestRef.current) await reloadRequestedProjects(treeRef.current);
if (throwOnSnapshotError) throw err;
} finally {
if (request === shellRequestRef.current) setShellStage("ready");
}
}, [applyRuntimeProjection, reloadProjectTopicLists]);
refreshRef.current = refresh;
@@ -561,6 +563,10 @@ export function ProjectTree({
return () => { shellRequestRef.current++; };
}, [refresh, refreshSignal]);
useEffect(() => {
if (shellStage !== "loading") return;
const timer = setTimeout(() => setShellStage("slow"), 250); return () => clearTimeout(timer);
}, [shellStage]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout> | undefined;
let latest: Parameters<Parameters<typeof onProjectTreeChangedV2>[0]>[0] | undefined;
@@ -2010,6 +2016,11 @@ export function ProjectTree({
);
const renderEmptyState = () => {
if (shellStage !== "ready") return <div className="project-tree__empty-state" role="status"><div className="project-tree__empty project-tree__empty--subtle">{t("projectTree.loadingProjects")}</div>
{shellStage === "slow" && <div className="project-tree__skeleton" aria-hidden="true">
<span className="project-tree__skeleton-bar" /><span className="project-tree__skeleton-bar project-tree__skeleton-bar--short" />
</div>}
</div>;
if (query.trim()) return <div className="project-tree__empty">{t("projectTree.emptyNoMatch")}</div>;
return (
<div className="project-tree__empty-state">
@@ -313,7 +313,10 @@ export function useProjectTreeOrganization({
const position = event.clientY < rect.top + rect.height / 2 ? "before" : "after";
const folder = tree.find(folder => projectTreeOrganizationKey(folder) === key);
const dragged = folder?.children?.find(row => projectSessionIdentity(row) === draggedID);
if (bindings.UpdateSessionOrganization && dragged) {
const targetGroup = (groupsRef.current[key] ?? []).find((group) => projectTreeGroupContainsNode(group, node));
if (dragged && targetGroup && !projectTreeGroupContainsNode(targetGroup, dragged)) {
mutateGroups(key, (groups) => moveNodeToGroup(groups, dragged, targetGroup.id), { kind: "set-group", target: projectNodeSelector(dragged), groupId: targetGroup.id });
} else if (bindings.UpdateSessionOrganization && dragged) {
void mutateSessionOrganization(bindings, { scope: context.scope, workspaceRoot: context.root, hostId: splitOrganizationKey(key).hostId },
{ kind: "move", target: projectNodeSelector(dragged), anchor: projectNodeSelector(node), position })
.then(saved => { setOrdersByKey(current => ({ ...current, [key]: saved.order })); return refresh({ reloadAllTopics: true }); })
@@ -326,7 +329,7 @@ export function useProjectTreeOrganization({
};
props.onDragEnd = clearTopicDrag;
return { className, props };
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, onTopicsChanged, refresh, showToast, tree]);
}, [bindings, clearTopicDrag, dragTopicID, dropTopic, loadGroups, mutateGroups, onTopicsChanged, refresh, showToast, tree]);
const removeTopicFromGroups = useCallback((node: ProjectNode) => {
const key = projectTreeOrganizationKey(node);
@@ -45,7 +45,9 @@ export function RemoteSessionSurface({ tab, session, surfaceCommitToken, onSurfa
const visiblePrompt = approval || ask || mcpInteraction;
const promptUpgradeRequired = Boolean(visiblePrompt && !tab.interactionTargetSupported);
const promptIdentityUnavailable = Boolean(visiblePrompt && tab.interactionTargetSupported && (
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId || !visiblePrompt.runtimeEpoch
// runtimeEpoch stays optional: a serve fences on it only when it has one,
// and a CLI serve never binds desktop routing metadata.
!tab.remote?.hostId || !tab.sessionId || !hasSessionGeneration(tab.sessionGeneration) || !visiblePrompt.turnId
));
const promptActionDisabled = promptUpgradeRequired || promptIdentityUnavailable;
const formUpgradeRequired = Boolean(extensionForm && !tab.extensionFormInstanceSupported);
@@ -102,7 +102,7 @@ export async function loadHistoryWindow(input: LoadInput): Promise<HistoryWindow
return "empty";
}
if (!result) { input.dispatch({ type: "history_older_error", error: "history page unavailable" }); return "empty"; }
if (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest)) {
if (result.kind !== "reload" && (!fingerprintMatches(expectedRevision, result.revisionKnown ? result.revision : undefined) || !digestMatches(expectedDigest, result.digest))) {
input.dispatch({ type: "history_older_error", error: "history identity changed" });
return "empty";
}
@@ -3,7 +3,11 @@ import { attentionChimeEventKey, clearAttentionChimeKeys, playAttentionChime, pl
import type { Translator } from "./i18n";
import type { ToastContextValue } from "./toast";
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string } } | { resetTabId?: string };
export type NotificationOperation = { event: AttentionChimeEvent & { err?: string; outcome?: string } } | { resetTabId?: string };
// Outcomes where the turn stopped to wait on the user; the host records them as
// paused or awaiting_delivery (desktop/topic_status.go), never as a finish.
const PAUSED_TURN_OUTCOMES = new Set(["final_readiness", "recovery_paused", "incomplete_read", "completion_uncertain"]);
type NotificationPorts = { activeTabId: string | undefined; t: Translator; showToast: ToastContextValue["showToast"] };
/** One owner deduplicates view events and authoritative background snapshots. */
@@ -72,7 +76,9 @@ export function createRuntimeNotifications(readPorts: () => NotificationPorts |
clearAttentionChimeKeys(seen, operation.resetTabId);
clearAttentionChimeKeys(pendingSeen, operation.resetTabId);
} else if (operation.event.kind === "turn_done") {
if (readPorts() && !operation.event.err) playSuccessChime();
if (!readPorts()) return;
if (PAUSED_TURN_OUTCOMES.has(operation.event.outcome ?? "")) playAttentionChime();
else if (!operation.event.err) playSuccessChime();
} else handleAttention(operation.event);
},
start() { stop = runtimeStateStore.subscribe(handleSnapshot); handleSnapshot(); },
+1
View File
@@ -1683,6 +1683,7 @@ export const en = {
"projectTree.colorPink": "Pink",
"projectTree.emptyNoMatch": "No matching projects or sessions",
"projectTree.emptyNoProjects": "No projects yet",
"projectTree.loadingProjects": "Reading projects…",
"projectTree.newTopicTooltip": "New session",
"projectTree.addProjectTooltip": "Add new project",
"projectTree.remoteOpenWindow": "Open in remote window",
+1
View File
@@ -1437,6 +1437,7 @@ export const zhTW: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "沒有匹配的專案或會話",
"projectTree.emptyNoProjects": "還沒有專案",
"projectTree.loadingProjects": "正在讀取專案…",
"projectTree.newTopicTooltip": "新建會話",
"projectTree.addProjectTooltip": "新增專案",
"projectTree.remoteOpenWindow": "在遠端視窗開啟",
+1
View File
@@ -1684,6 +1684,7 @@ export const zh: Record<DictKey, string> = {
"projectTree.colorPink": "粉色",
"projectTree.emptyNoMatch": "没有匹配的项目或会话",
"projectTree.emptyNoProjects": "还没有项目",
"projectTree.loadingProjects": "正在读取项目…",
"projectTree.newTopicTooltip": "新建会话",
"projectTree.addProjectTooltip": "添加新项目",
"projectTree.remoteOpenWindow": "在远程窗口打开",
+5 -5
View File
@@ -35,7 +35,7 @@ require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/aymanbagabas/go-udiff v0.4.1 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.0 // indirect
github.com/bmatcuk/doublestar/v4 v4.10.2 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -45,13 +45,13 @@ require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/kr/fs v0.1.0 // indirect
github.com/larksuite/oapi-sdk-go/v3 v3.11.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-pointer v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.29 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 // indirect
github.com/mattn/go-runewidth v0.0.30 // indirect
github.com/modelcontextprotocol/go-sdk v1.8.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/pkg/sftp v1.13.11 // indirect
@@ -77,7 +77,7 @@ require (
modernc.org/libc v1.75.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
mvdan.cc/sh/v3 v3.14.0 // indirect
mvdan.cc/sh/v3 v3.14.1 // indirect
)
replace reasonix => ../
+10 -10
View File
@@ -10,8 +10,8 @@ github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJ
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
@@ -56,8 +56,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -70,10 +70,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 h1:Sxti6bD15KmHiTqcJ54nt9G6885m8DVwAxZX6Iu4Kjc=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
@@ -228,5 +228,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
+58 -2
View File
@@ -5,11 +5,14 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"reasonix/desktop/internal/workspacestate"
"reasonix/internal/agent"
"reasonix/internal/session"
"reasonix/internal/store"
)
// Explicit source archives stage content as archived from the beginning. They
@@ -38,11 +41,29 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
slog.Warn("desktop: historical archive failed", "source_key", id, "operation", operationID, "err", err)
return SessionMutationResult{}, sessionOperationErrorForTarget(err, id, operationID)
}
archivedPaths := []string{source.path}
for _, sibling := range recoveredLegacySiblings(source) {
siblingID, siblingSource, err := a.historicalSourceForSelector(SessionSelector{Source: &SessionSourceRef{HostID: localDesktopHostID, Path: sibling}})
if err != nil {
slog.Warn("desktop: recovered sibling archive skipped", "source_key", id, "err", err)
continue
}
siblingResult, err := a.archiveHistoricalSourceWithOperation(ctx, siblingID, siblingSource,
"archive-source-"+strings.TrimPrefix(newTabID(), "tab_"))
if err != nil {
slog.Warn("desktop: recovered sibling archive failed", "source_key", siblingID, "err", err)
continue
}
result.IdentityAliases = append(result.IdentityAliases, siblingResult.IdentityAliases...)
archivedPaths = append(archivedPaths, siblingSource.path)
}
c := &a.historicalImports
c.mu.Lock()
for i := range c.catalog {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, source.path) {
c.catalog[i].sourceChanged = false
for _, path := range archivedPaths {
if c.catalog[i].node.Source != nil && sameDesktopPath(c.catalog[i].node.Source.Path, path) {
c.catalog[i].sourceChanged = false
}
}
}
c.mu.Unlock()
@@ -50,6 +71,41 @@ func (a *App) archiveHistoricalSource(selector SessionSelector) (SessionMutation
return result, nil
}
// recoveredLegacySiblings lists the other recovery copies of the conversation a
// recovered legacy source was copied from: same directory, recovered, and the
// same parent in the metadata their writer recorded. The sidebar lists each
// copy as its own row of the same conversation, so archiving one row alone
// only brings the next copy up in its place.
func recoveredLegacySiblings(source historicalSource) []string {
if source.format != "legacy" {
return nil
}
meta, ok, err := agent.LoadBranchMeta(source.path)
if err != nil || !ok || !meta.Recovered || strings.TrimSpace(meta.ParentID) == "" {
return nil
}
dir := filepath.Dir(source.path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var siblings []string
for _, entry := range entries {
if entry.IsDir() || !store.IsSessionTranscriptName(entry.Name()) {
continue
}
path := filepath.Join(dir, entry.Name())
if sameDesktopPath(path, source.path) {
continue
}
other, ok, err := agent.LoadBranchMeta(path)
if err == nil && ok && other.Recovered && other.ParentID == meta.ParentID {
siblings = append(siblings, path)
}
}
return siblings
}
func (a *App) archiveHistoricalSourceWithOperation(ctx context.Context, id string, source historicalSource, operationID string) (SessionMutationResult, error) {
release, err := acquireHistoricalSource(ctx, id, source)
if err != nil {
@@ -0,0 +1,98 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A write conflict leaves one recovery copy per writer, each listed as its own
// row of the same conversation, so archiving any of them archives them all.
func TestArchivingRecoveredLegacyRowArchivesEveryCopy(t *testing.T) {
isolateDesktopUserDirs(t)
dir := config.SessionDir()
parent := filepath.Join(dir, "20260826-101500.000000000-fake-model.jsonl")
p := agent.NewSession("system")
p.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
if err := p.Save(parent); err != nil {
t.Fatal(err)
}
meta := agent.BranchMeta{Scope: "global", TopicID: "topic_jd", TopicTitle: "shops from my search"}
for _, answer := range []string{"first writer", "second writer", "third writer"} {
s := agent.NewSession("system")
s.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "shops from my search"})
s.Add(provider.Message{ID: "a-" + answer, Role: provider.RoleAssistant, Content: answer})
if _, err := s.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: parent, Reason: "conflict", BranchMeta: meta}); err != nil {
t.Fatal(err)
}
}
entries, _ := os.ReadDir(dir)
for _, e := range entries {
if strings.HasPrefix(e.Name(), "20260826-101500.000000000-fake-model.") {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
// An unrelated recovered conversation must survive.
other := filepath.Join(dir, "20260826-120000.000000000-fake-model.jsonl")
o := agent.NewSession("system")
o.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "another conversation"})
if err := o.Save(other); err != nil {
t.Fatal(err)
}
if _, err := o.SaveConflictRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: other, Reason: "conflict",
BranchMeta: agent.BranchMeta{Scope: "global", TopicID: "topic_other", TopicTitle: "another conversation"}}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
recoveredRows := func(topic string) []ProjectNode {
t.Helper()
if err := catalog.ReconcileDirectory(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
var rows []ProjectNode
for _, n := range page.Items {
if n.Recovered && n.TopicID == topic {
rows = append(rows, n)
}
}
return rows
}
rows := recoveredRows("topic_jd")
if len(rows) != 3 {
t.Fatalf("recovered rows before archive = %d, want the 3 copies", len(rows))
}
target := rows[0]
if _, err := app.ArchiveSessionTarget(SessionSelector{Ref: target.Session, Source: target.Source, SessionPath: target.SessionPath}); err != nil {
t.Fatal(err)
}
if left := recoveredRows("topic_jd"); len(left) != 0 {
t.Fatalf("archiving one recovered row left %d copies of the same conversation listed", len(left))
}
if kept := recoveredRows("topic_other"); len(kept) != 1 {
t.Fatalf("an unrelated recovered conversation was archived too: %d rows left, want 1", len(kept))
}
}
+12 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
@@ -26,7 +27,7 @@ func TestRemoteInboxQueueCapabilitiesAndIdentity(t *testing.T) {
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
a, tab := remoteRuntimeTestApp(server.Client())
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
@@ -105,3 +106,13 @@ func TestTargetGuidanceQueuesEndedTurnAndRejectsReplacementSession(t *testing.T)
t.Fatal("legacy request bypassed the session fence")
}
}
// operatorServeHandler stands in for the remote client, which holds the
// launch token a serve requires for mutations.
func operatorServeHandler(s *serve.Server) http.Handler {
h := s.Handler()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Header.Set("Authorization", "Bearer "+s.AuthToken())
h.ServeHTTP(w, r)
})
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestRemoteInboxTargetLostReceiptOnlyQueriesOriginalRequest(t *testing.T) {
if err := ctrl.SetInboxPaused(true); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
posts, lookups := 0, 0
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+4 -1
View File
@@ -131,7 +131,10 @@ func (a *App) ClearMCPServerAuthentication(name string) error {
return err
}
}
if _, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name); err != nil {
if err := config.KeepMCPDecisionAcross(root, name, func() (config.PluginEntry, error) {
updated, _, _, err := config.ClearPluginAuthenticationInSourceForRoot(root, name)
return updated, err
}); err != nil {
return err
}
disconnectMCPServerControllers(name, ctrl, controllers)
+1 -1
View File
@@ -477,7 +477,7 @@ func setConnectionsCredentialConfig(c *config.Config, names []string, key string
}
entries = append(entries, *p)
}
env, err := c.StageModelCredentialLocked(key)
env, err := c.RotateModelCredentialLocked(names, key)
if err != nil {
return err
}
+61 -61
View File
@@ -68,8 +68,8 @@ importers:
specifier: 1.7.5
version: 1.7.5(@modelcontextprotocol/[email protected]([email protected]))([email protected]([email protected]))([email protected])([email protected])
'@tanstack/react-virtual':
specifier: ^3.14.10
version: 3.14.11([email protected]([email protected]))([email protected])
specifier: ^3.14.13
version: 3.14.13([email protected]([email protected]))([email protected])
'@xterm/addon-fit':
specifier: 0.11.0
version: 0.11.0
@@ -89,8 +89,8 @@ importers:
specifier: 3.0.1
version: 3.0.1
katex:
specifier: ^0.18.6
version: 0.18.7
specifier: ^0.18.9
version: 0.18.9
lucide-react:
specifier: ^1.41.0
version: 1.43.0([email protected])
@@ -156,11 +156,11 @@ importers:
specifier: ^19.2.7
version: 19.2.7(@types/[email protected])
'@typescript-eslint/parser':
specifier: ^8.69.0
version: 8.70.0([email protected])([email protected])
specifier: ^8.70.1
version: 8.70.1([email protected])([email protected])
'@vitejs/plugin-react':
specifier: ^6.1.1
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))
version: 6.1.1([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))
decode-named-character-reference:
specifier: ^1.3.0
version: 1.3.0
@@ -186,14 +186,14 @@ importers:
specifier: ^5.51.2
version: 5.51.2
tsx:
specifier: ^4.23.13
version: 4.23.13
specifier: ^4.23.15
version: 4.23.15
typescript:
specifier: ^6.0.3
version: 6.0.3
vite:
specifier: ^8.2.2
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
version: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
packages:
@@ -776,14 +776,14 @@ packages:
'@standard-schema/[email protected]':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
'@tanstack/[email protected]1':
resolution: {integrity: sha512-SStWf8bYdTgAquYqG4Pi2+d1XimQKoCIJ94H3jsm0D6UA0yqffvWuvUzrrQ4idewFWq8BSPpahnmLVosJIAs6g==}
'@tanstack/[email protected]3':
resolution: {integrity: sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/[email protected].9':
resolution: {integrity: sha512-M8Bzy7CCMvUjRiuoHVH9mRjyUVczRs8v8RcUEphLFGc445ZP4KQ15Y1sLqhQqJi/HgGJrxfCHnEnIX0x9mVrBg==}
'@tanstack/[email protected].11':
resolution: {integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==}
'@types/[email protected]':
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
@@ -940,41 +940,41 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
peerDependencies:
typescript: '>=4.8.4 <6.1.0'
'@typescript-eslint/[email protected].0':
resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==}
'@typescript-eslint/[email protected].1':
resolution: {integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ungap/[email protected]':
@@ -1823,8 +1823,8 @@ packages:
resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==}
hasBin: true
[email protected].7:
resolution: {integrity: sha512-h+UCwkZ+4Jz8WQ7MLGfj7UVFrRCizGb912fwF4luGdYsC5paYG1vx+jy+KRcC/XkpjGva/P7nAWuxNnPzRvzHw==}
[email protected].9:
resolution: {integrity: sha512-8ad9RyoKsb/g8/yLFE+KAlP+DhbCTRUNi/V9XGsxn0R+trJJltNwzcDNo0q/DEkOy5fQUQTAQyCXCYSE+OakTQ==}
hasBin: true
[email protected]:
@@ -2507,8 +2507,8 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
[email protected]3:
resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
[email protected]5:
resolution: {integrity: sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==}
engines: {node: '>=18.0.0'}
hasBin: true
@@ -3255,13 +3255,13 @@ snapshots:
'@standard-schema/[email protected]': {}
'@tanstack/[email protected]1([email protected]([email protected]))([email protected])':
'@tanstack/[email protected]3([email protected]([email protected]))([email protected])':
dependencies:
'@tanstack/virtual-core': 3.17.9
'@tanstack/virtual-core': 3.17.11
react: 19.2.8
react-dom: 19.2.8([email protected])
'@tanstack/[email protected].9': {}
'@tanstack/[email protected].11': {}
'@types/[email protected]': {}
@@ -3444,44 +3444,44 @@ snapshots:
'@types/[email protected]': {}
'@typescript-eslint/[email protected].0([email protected])([email protected])':
'@typescript-eslint/[email protected].1([email protected])([email protected])':
dependencies:
'@typescript-eslint/scope-manager': 8.70.0
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/typescript-estree': 8.70.0([email protected])
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/scope-manager': 8.70.1
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/typescript-estree': 8.70.1([email protected])
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3
eslint: 10.10.0
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
'@typescript-eslint/types': 8.70.1
debug: 4.4.3
typescript: 6.0.3
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/visitor-keys': 8.70.1
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
typescript: 6.0.3
'@typescript-eslint/[email protected].0': {}
'@typescript-eslint/[email protected].1': {}
'@typescript-eslint/[email protected].0([email protected])':
'@typescript-eslint/[email protected].1([email protected])':
dependencies:
'@typescript-eslint/project-service': 8.70.0([email protected])
'@typescript-eslint/tsconfig-utils': 8.70.0([email protected])
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/visitor-keys': 8.70.0
'@typescript-eslint/project-service': 8.70.1([email protected])
'@typescript-eslint/tsconfig-utils': 8.70.1([email protected])
'@typescript-eslint/types': 8.70.1
'@typescript-eslint/visitor-keys': 8.70.1
debug: 4.4.3
minimatch: 10.2.6
semver: 7.8.5
@@ -3491,9 +3491,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@typescript-eslint/[email protected].0':
'@typescript-eslint/[email protected].1':
dependencies:
'@typescript-eslint/types': 8.70.0
'@typescript-eslint/types': 8.70.1
eslint-visitor-keys: 5.0.1
'@ungap/[email protected]': {}
@@ -3503,10 +3503,10 @@ snapshots:
d3-selection: 3.0.0
d3-transition: 3.0.1([email protected])
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]3))':
'@vitejs/[email protected]([email protected](@types/[email protected])([email protected])([email protected])([email protected]5))':
dependencies:
'@rolldown/pluginutils': 1.0.1
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]3)
vite: 8.2.2(@types/[email protected])([email protected])([email protected])([email protected]5)
'@xmldom/[email protected]': {}
@@ -4425,7 +4425,7 @@ snapshots:
dependencies:
commander: 8.3.0
[email protected].7:
[email protected].9:
dependencies:
commander: 15.0.0
@@ -5374,7 +5374,7 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
[email protected]3:
[email protected]5:
dependencies:
esbuild: 0.28.1
optionalDependencies:
@@ -5476,7 +5476,7 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
[email protected](@types/[email protected])([email protected])([email protected])([email protected]3):
[email protected](@types/[email protected])([email protected])([email protected])([email protected]5):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -5488,7 +5488,7 @@ snapshots:
esbuild: 0.28.1
fsevents: 2.3.3
terser: 5.51.2
tsx: 4.23.13
tsx: 4.23.15
[email protected]:
dependencies:
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/boot"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/plugin"
)
// enableProjectMCPForTest records the user's enable decision for each server
// root's project files declare with auto_start on.
func enableProjectMCPForTest(t testing.TB, root string) {
t.Helper()
enableProjectMCPForWorkspace(t, root, root)
}
// enableProjectMCPForWorkspace records the decision under workspace, which the
// test app's global tab leaves empty while config still loads from root.
func enableProjectMCPForWorkspace(t testing.TB, root, workspace string) {
t.Helper()
cfg, err := config.LoadForRootReadOnly(root)
if err != nil {
t.Fatal(err)
}
for _, p := range cfg.Plugins {
if p.Source.ProjectScoped() && p.ShouldAutoStart() {
if err := config.DefaultMCPActivationStore().SetServerEnabled(p, workspace, true); err != nil {
t.Fatal(err)
}
}
}
}
// Project configuration cannot choose MCP servers the desktop starts: without
// the user's decision the shared host never connects to one.
func TestDesktopSharedHostProjectMCPWaitsForTheUser(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
srv := desktopMCPHTTPServer(t)
defer srv.Close()
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "h"
type = "http"
url = %q
`, srv.URL), 0o644); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
sharedHost := plugin.NewHost()
defer sharedHost.Close()
ctrl, err := boot.Build(ctx, boot.Options{
WorkspaceRoot: dir,
SessionDir: filepath.Join(dir, "sessions"),
SharedHost: sharedHost,
Stderr: io.Discard,
})
if err != nil {
t.Fatalf("boot.Build: %v", err)
}
defer ctrl.Close()
time.Sleep(time.Second)
if sharedHost.HasClient("h") {
t.Fatal("project MCP connected with no user decision")
}
}
func TestCapabilitiesShowsDefaultMCPAsAutomaticIdleNotDisabled(t *testing.T) {
isolateDesktopUserDirs(t)
dir := robustTempDir(t)
t.Chdir(dir)
if err := os.WriteFile(filepath.Join(dir, "reasonix.toml"), []byte(`
[[plugins]]
name = "playwright"
command = "npx"
args = ["-y", "@playwright/mcp"]
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForWorkspace(t, dir, "")
app := NewApp()
app.setTestCtrl(control.New(control.Options{Host: plugin.NewHost()}), "")
defer func() {
if c := app.activeCtrl(); c != nil {
c.Close()
}
}()
view := app.Capabilities()
for _, s := range view.Servers {
if s.Name == "playwright" {
if s.Status != "deferred" || s.StartIntent != "automatic" || s.RuntimeState != "idle" {
t.Fatalf("default MCP view = %+v, want deferred automatic idle", s)
}
return
}
}
t.Fatalf("playwright MCP missing from Capabilities: %+v", view.Servers)
}
+38
View File
@@ -0,0 +1,38 @@
package main
import "encoding/json"
// remoteContextSnapshot reads the selected remote session, then rejects a
// response that raced with a reconnect or a switch to another session.
func (a *App) remoteContextSnapshot(tabID string) (int, int, bool) {
a.remoteTabMu.Lock()
tab := a.remoteTabs[tabID]
if tab == nil || tab.client == nil || tab.state != "ready" || tab.routing.rehydratingPath != "" {
a.remoteTabMu.Unlock()
return 0, 0, false
}
client, base := tab.client, tab.base
path, generation, revision := tab.routing.currentPath, tab.gen, tab.routing.pathRevision
a.remoteTabMu.Unlock()
ctx, cancel := commandContext(a)
defer cancel()
data, err := serveGet(ctx, client, serveURL(base, "/context"), path)
if err != nil {
return 0, 0, false
}
var value struct {
Used int `json:"used"`
Window int `json:"window"`
}
if json.Unmarshal(data, &value) != nil {
return 0, 0, false
}
a.remoteTabMu.Lock()
current := a.remoteTabs[tabID]
valid := current == tab && current.client == client && current.gen == generation &&
current.routing.pathRevision == revision && current.routing.currentPath == path &&
current.routing.rehydratingPath == "" && current.state == "ready"
a.remoteTabMu.Unlock()
return value.Used, value.Window, valid
}
+64
View File
@@ -0,0 +1,64 @@
package main
import "testing"
func TestRemoteContextPanelReadsServeContext(t *testing.T) {
fs := newFakeServe(t, "s3cret", nil)
fs.newSessionPath = "/sessions/remote.jsonl"
kernel := &fakeRemoteKernel{
statuses: []RemoteConnectionStatusView{{HostID: "box", State: "connected"}},
ensureView: RemoteServerView{HostID: "box", State: "ready", LocalURL: fs.server.URL},
ensureToken: "s3cret",
}
seedBridgeTestHost(t, "box")
a := &App{remoteRuntime: kernel}
cleanupRemoteTabPumps(t, a)
meta, err := a.OpenRemoteProjectTab("box", "~/app", RemoteTabOpenOptions{NewSession: true})
if err != nil {
t.Fatal(err)
}
waitForTabState(t, a, meta.ID, "ready")
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 || got.WindowTokens != 128 {
t.Fatalf("remote panel context = %d/%d, want Serve /context value 10/128", got.UsedTokens, got.WindowTokens)
}
if got := a.ContextUsageForTab(meta.ID); got.Used != 10 || got.Window != 128 {
t.Fatalf("remote usage context = %d/%d, want 10/128", got.Used, got.Window)
}
fs.mu.Lock()
expected := fs.contextExpected
fs.mu.Unlock()
a.remoteTabMu.Lock()
selected := a.remoteTabs[meta.ID].routing.currentPath
a.remoteTabMu.Unlock()
if expected != selected {
t.Fatalf("remote context request targeted %q, want selected %q", expected, selected)
}
a.remoteTabMu.Lock()
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-a"
a.remoteTabs[meta.ID].routing.pathRevision++
a.remoteTabMu.Unlock()
if got := a.ContextPanel(meta.ID); got.UsedTokens != 10 {
t.Fatalf("canonical remote panel used tokens = %d, want 10", got.UsedTokens)
}
fs.mu.Lock()
expected = fs.contextExpected
fs.mu.Unlock()
if expected != remoteSessionIDRoutePrefix+"canonical-a" {
t.Fatalf("canonical context header = %q", expected)
}
started, release := make(chan struct{}, 1), make(chan struct{})
fs.mu.Lock()
fs.contextStarted, fs.contextRelease = started, release
fs.mu.Unlock()
done := make(chan ContextPanelInfo, 1)
go func() { done <- a.ContextPanel(meta.ID) }()
<-started
a.remoteTabMu.Lock()
a.remoteTabs[meta.ID].routing.currentPath = remoteSessionIDRoutePrefix + "canonical-b"
a.remoteTabs[meta.ID].routing.pathRevision++
a.remoteTabMu.Unlock()
close(release)
if got := <-done; got.UsedTokens != 0 || got.WindowTokens != 0 {
t.Fatalf("stale remote context reached the switched tab: %+v", got)
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ func TestRemoteRuntimeInboxReceiptSnapshotAndConsumptionOverHTTP(t *testing.T) {
}
}
})
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
server := httptest.NewServer(operatorServeHandler(serve.New(ctrl, nil, config.ServeConfig{})))
defer server.Close()
a, tab := remoteRuntimeTestApp(server.Client())
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
+1
View File
@@ -110,6 +110,7 @@ func TestRemoteModelSourceRefreshesAutonomousHTTPRunAndRetiresOldRoute(t *testin
request := httptest.NewRequest(http.MethodPost, "/submit", strings.NewReader(`{"input":"autonomous next run"}`)).WithContext(ctx)
request.Host = "127.0.0.1"
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+srv.AuthToken())
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != http.StatusAccepted {
+22 -1
View File
@@ -53,6 +53,8 @@ type fakeServe struct {
eventsCloseEarly bool // return immediately after the initial 200 frames
statusPayload string
statusAfterCancel string
contextExpected string
contextStarted, contextRelease chan struct{}
}
func (fs *fakeServe) eventsCount() int { fs.mu.Lock(); defer fs.mu.Unlock(); return fs.eventsConns }
@@ -305,6 +307,25 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
mux.HandleFunc("GET "+path, func(w http.ResponseWriter, r *http.Request) {
fs.record(r.Method, path, "")
responsePayload := payload
if path == "/context" {
fs.mu.Lock()
fs.contextExpected = r.Header.Get(expectedSessionPathHeader)
if id := r.Header.Get(expectedSessionIDHeader); id != "" {
fs.contextExpected = remoteSessionIDRoutePrefix + id
}
started, release := fs.contextStarted, fs.contextRelease
fs.mu.Unlock()
if started != nil {
started <- struct{}{}
}
if release != nil {
select {
case <-release:
case <-r.Context().Done():
return
}
}
}
if path == "/history" {
fs.mu.Lock()
fail := fs.failHistory
@@ -333,7 +354,7 @@ func newFakeServe(t *testing.T, token string, sessions []serveSessionEntry) *fak
})
}
snapshot("/history", `[{"role":"user","content":"hi"}]`)
snapshot("/context", `{"used":10}`)
snapshot("/context", `{"used":10,"window":128}`)
snapshot("/todos", `[]`)
snapshot("/checkpoints", `[{"turn":1}]`)
snapshot("/models", `{"current":"remote/chat","label":"chat","models":[{"ref":"remote/chat","provider":"remote","model":"chat","active":true}]}`)
+8 -1
View File
@@ -442,11 +442,18 @@ func (a *App) clearRemotePendingEvent(tabID, kind, callID string) {
// serveGet fetches a JSON member of the tab snapshot, returning the raw
// payload for verbatim passthrough.
func serveGet(ctx context.Context, client *http.Client, url string) (json.RawMessage, error) {
func serveGet(ctx context.Context, client *http.Client, url string, expectedPath ...string) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if len(expectedPath) != 0 && strings.TrimSpace(expectedPath[0]) != "" {
if sessionID, ok := strings.CutPrefix(expectedPath[0], remoteSessionIDRoutePrefix); ok {
req.Header.Set(expectedSessionIDHeader, sessionID)
} else {
req.Header.Set(expectedSessionPathHeader, expectedPath[0])
}
}
resp, err := client.Do(req)
if err != nil {
return nil, err
@@ -0,0 +1,69 @@
package main
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/config"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/serve"
"reasonix/internal/transcript"
)
// A Serve whose foreground is a path-backed session (the controller POST
// /resume installs for a historical .jsonl) still serves Follow v2 through the
// legacy projection, so the handshake a desktop attaches with must say so.
func TestRemoteFollowAttachesToServeWithPathBackedForeground(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "question"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
bc := serve.NewBroadcaster()
ctrl := control.New(control.Options{Executor: agent.New(nil, nil, sess, agent.Options{}, bc), SessionDir: dir, SessionPath: path, Sink: bc})
defer ctrl.Close()
if ctrl.UsesExclusiveSession() {
t.Fatal("fixture must put a path-backed session in the foreground")
}
server := httptest.NewServer(serve.New(ctrl, bc, config.ServeConfig{AuthMode: "token", Token: "fixture-token"}).Handler())
defer server.Close()
client, err := newServeHTTPClient(server.URL)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
capabilities, err := serveHandshakeCapabilities(ctx, client, server.URL, "fixture-token")
if err != nil {
t.Fatal(err)
}
tab := &remoteTab{id: "remote", state: "ready", client: client, base: server.URL, gen: 1,
routing: remoteTabSessionRouting{currentPath: path}, capabilities: map[string]bool{}}
for _, capability := range capabilities {
tab.capabilities[capability] = true
}
app := &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}
followed, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{})
if err != nil {
t.Fatalf("follow against a path-backed foreground (capabilities %v): %v", capabilities, err)
}
if followed.ProtocolVersion != transcript.FollowProtocolVersion || followed.Snapshot == nil || followed.Subscription == "" || followed.StorageBackend != "legacy" {
t.Fatalf("follow = %+v", followed)
}
if len(followed.Snapshot.Records) != 2 {
t.Fatalf("follow snapshot records = %d, want 2", len(followed.Snapshot.Records))
}
if _, err := app.RemoteTranscriptFollowForTab(tab.id, transcript.FollowRequest{Subscription: followed.Subscription, Close: true}); err != nil {
t.Fatalf("close follow: %v", err)
}
}
@@ -0,0 +1,98 @@
package main
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/provider"
"reasonix/internal/sessioncatalog"
)
// A save event indexes a transcript under the global fallback scope when its
// sidecar names none, while archive assigns the owner from the project store
// the file lives in. The row must leave the sidebar it was listed in anyway.
func TestArchivedSourceOwnedByAnotherWorkspaceLeavesSidebar(t *testing.T) {
for _, heads := range []int{1, 2} {
name := "single-head"
if heads == 2 {
name = "branched"
}
t.Run(name, func(t *testing.T) {
isolateDesktopUserDirs(t)
workspace := filepath.Join(t.TempDir(), "project")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
if err := saveProjectsFile(desktopProjectFile{Projects: []desktopProject{{Root: workspace}}}); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(workspace)
path := filepath.Join(dir, "20260922-122527.100224400-deepseek-chat.jsonl")
legacy := agent.NewSession("system")
legacy.Add(provider.Message{ID: "q0", Role: provider.RoleUser, Content: "question"})
legacy.Add(provider.Message{ID: "a0", Role: provider.RoleAssistant, Content: "answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
if heads == 2 {
if _, err := legacy.ForkHead(path, legacy.Snapshot()[1].ID, agent.HeadKindFork, "sibling"); err != nil {
t.Fatal(err)
}
legacy.Add(provider.Message{Role: provider.RoleAssistant, Content: "sibling answer"})
if err := legacy.Save(path); err != nil {
t.Fatal(err)
}
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{TopicID: "topic_unscoped"}); err != nil {
t.Fatal(err)
}
app := NewApp()
app.ctx = t.Context()
pinDesktopSessionRoot(t, app)
installNoopRuntimeEvents(app)
t.Cleanup(app.closeSessionServices)
catalog, err := sessioncatalog.Open(context.Background(), sessioncatalog.Options{InMemory: true, DisableRepair: true, MetadataOnly: true})
if err != nil {
t.Fatal(err)
}
app.sessionCatalog.Store(catalog)
t.Cleanup(func() { app.stopSessionCatalog(time.Second) })
globalRows := func() []ProjectNode {
t.Helper()
// The desktop's persist observer indexes each save under this fallback.
if err := catalog.IndexSessionPath(context.Background(), sessioncatalog.DirectoryTarget{Path: dir, Scope: "global"}, path); err != nil {
t.Fatal(err)
}
page, err := app.ListProjectTopics(ProjectTopicPageRequest{Scope: "global", Limit: 50})
if err != nil {
t.Fatal(err)
}
app.ReleaseReadSnapshot(page.SnapshotID)
return page.Items
}
rows := globalRows()
if len(rows) != heads {
t.Fatalf("global rows before archive = %+v, want %d", rows, heads)
}
for _, row := range rows {
result, err := app.ArchiveSessionTarget(SessionSelector{Ref: row.Session, Source: row.Source, SessionPath: row.SessionPath})
if err != nil || !result.Committed {
t.Fatalf("archive %+v = %+v, %v", row.Source, result, err)
}
}
if left := globalRows(); len(left) != 0 {
var operationErr *SessionOperationError
err := app.SetSessionPinned(SessionSelector{Source: left[0].Source, SessionPath: left[0].SessionPath}, true)
if errors.As(err, &operationErr) {
t.Fatalf("archived rows stay listed (%d) and refuse every action with %q", len(left), operationErr.Code)
}
t.Fatalf("archived rows stay listed: %+v", left)
}
})
}
}
+1
View File
@@ -405,6 +405,7 @@ args = ["serve"]
`), 0o644); err != nil {
t.Fatal(err)
}
enableProjectMCPForTest(t, root)
draft, err := a.seedPreviousDraftForTarget("project", root)
if err != nil {
t.Fatal(err)
+1 -1
View File
@@ -59,7 +59,7 @@ func (a *App) listSessionsFromDir(dir, active string) []SessionMeta {
Preview: node.Preview, Turns: node.Turns, TurnsState: node.TurnsState,
CreatedAt: node.CreatedAt, LastActivityAt: node.LastActivityAt, ModTime: node.LastActivityAt})
}
adopted := adoptedSourceRows(state, "")
adopted := adoptedSourceRows(state)
catalog := a.sessionCatalog.Load()
if catalog == nil {
return v3
+2 -4
View File
@@ -18,12 +18,10 @@ var sourceHeadRows sync.Map
// A mapped DAG head must not return as an unidentified path placeholder while
// its index is unavailable. This is a display filter, not an ownership alias:
// independently identified siblings remain eligible for their own rows.
func adoptedSourceRows(state workspacestate.State, workspaceID string) map[string]bool {
// Adoption belongs to the source, not to the workspace a sidecar lists it in.
func adoptedSourceRows(state workspacestate.State) map[string]bool {
adopted := map[string]bool{}
for _, mapping := range state.SourceMappings {
if workspaceID != "" && mapping.WorkspaceID != workspaceID {
continue
}
for _, key := range state.SourceKeys(mapping.SourceKey) {
adopted["source\x00local\x00"+key] = true
}
+4 -3
View File
@@ -142,17 +142,18 @@ func (a *App) lazyProjectTopicSnapshot(req ProjectTopicPageRequest, reader works
func lazyTopicSourceExclusions(state workspacestate.State, workspaceID string) ([]string, bool) {
excluded := []string{}
for _, mapping := range state.SourceMappings {
if mapping.WorkspaceID != workspaceID {
continue
}
// Canonical directories have one source identity even if an older
// migration receipt retained their originating head ID.
if mapping.Format != "canonical" && !sourceMappingHasPathAlias(mapping) {
if mapping.WorkspaceID != workspaceID {
continue
}
// The catalog may not yet know about a new sibling, or the head
// index may be unavailable. Only the head-aware adapter can filter
// adopted identities without inventing a new path-only source.
return nil, false
}
// A sidecar can list a file outside the workspace that adopted it.
excluded = append(excluded, mapping.Path)
}
return excluded, true
+1 -1
View File
@@ -177,7 +177,7 @@ func (a *App) materializeProjectTopics(req ProjectTopicPageRequest, reader works
}
workspace.SessionIDs = admittedWorkspaceTopicMembers(req, state, workspace)
infos, _ := listWorkspaceSessionInfo(a.bootContext(), reader, workspace.SessionIDs)
adopted := adoptedSourceRows(state, workspaceID)
adopted := adoptedSourceRows(state)
adoptedTopics := state.AdoptedTopicIDs(workspaceID)
all := req
all.Cursor = ""
+3
View File
@@ -2,6 +2,7 @@ package main
import (
"reasonix/internal/control"
"reasonix/internal/gitcmd"
goaldomain "reasonix/internal/goal"
"reasonix/internal/session"
)
@@ -75,6 +76,8 @@ type TabMeta struct {
ModelSettingsPending bool `json:"modelSettingsPending,omitempty"`
Active bool `json:"active"`
Cwd string `json:"cwd"`
// repo is the tab session's git identity for WorkspaceRoot; never serialized.
repo gitcmd.Repo
}
// setAuthenticationMeta only exposes pending settings belonging to this runtime.
+12 -2
View File
@@ -2138,7 +2138,7 @@ type wireEventTab struct {
func enrichTabMeta(meta TabMeta) TabMeta {
if meta.Active {
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot)
meta.GitBranch = workspaceGitBranchForMeta(meta.WorkspaceRoot, meta.repo)
}
return meta
}
@@ -2146,7 +2146,7 @@ func enrichTabMeta(meta TabMeta) TabMeta {
func enrichTabMetas(metas []TabMeta) []TabMeta {
for i := range metas {
if metas[i].Active {
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot)
metas[i].GitBranch = workspaceGitBranchForMeta(metas[i].WorkspaceRoot, metas[i].repo)
}
}
return metas
@@ -2192,6 +2192,9 @@ func (a *App) tabMeta(tab *WorkspaceTab, active bool) TabMeta {
Cwd: tab.WorkspaceRoot,
IsolatedWorktree: floor.isolated,
}
if repo, ok := sessionWorkspaceRepo(tab.Ctrl, tab.WorkspaceRoot); ok {
m.repo = repo
}
if strings.TrimSpace(tab.SessionID) != "" {
m.Session = &session.SessionRef{HostID: "local", SessionID: strings.TrimSpace(tab.SessionID)}
}
@@ -6669,6 +6672,13 @@ type ChangedFileInfo struct {
// ContextPanel returns the context usage, read files, and changed files for a
// specific tab.
func (a *App) ContextPanel(tabID string) ContextPanelInfo {
if a.isRemoteTab(tabID) {
used, window, ok := a.remoteContextSnapshot(tabID)
if ok {
return ContextPanelInfo{UsedTokens: used, WindowTokens: window, ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
}
return ContextPanelInfo{ReadFiles: []readFileRecord{}, ChangedFiles: []ChangedFileInfo{}}
}
read := a.captureContextRead(tabID)
ctrl, telemetry := read.ctrl, read.telemetry
if read.tab == nil {
+3 -1
View File
@@ -480,7 +480,9 @@ func (a *App) refreshTabMetaExtras(tab *WorkspaceTab) {
gitBranch := ""
if root != "" {
gitBranch = workspaceGitBranch(root)
if repo, err := workspaceRepo(root, ctrl); err == nil {
gitBranch = workspaceGitBranch(repo)
}
}
imageInputEnabled := false
visionFallbackEnabled := false
@@ -0,0 +1,255 @@
package main
import (
"cmp"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"sync"
"testing"
"time"
"reasonix/internal/config"
"reasonix/internal/transcript"
)
// rendererFollowCursor mirrors TranscriptFollowClient.validate in
// desktop/frontend/src/lib/transcriptFollowClient.ts (lines 174-220): any
// error it returns is one that makes the renderer drop the subscription.
type rendererFollowCursor struct {
revision, coverage uint64
indexes map[string]uint64
attempts map[string]string
results map[string]uint64
resultOrder []string
deltas map[string]string
}
func newRendererFollowCursor(snapshot *transcript.Snapshot) *rendererFollowCursor {
c := &rendererFollowCursor{revision: snapshot.ProjectionRevision, coverage: snapshot.CoveredThroughSeq,
indexes: map[string]uint64{}, attempts: map[string]string{}, results: map[string]uint64{}, deltas: map[string]string{}}
for _, attempt := range snapshot.ActiveAttempts {
c.indexes[attempt.ID], c.attempts[attempt.ID] = attempt.NextIndex, attempt.MessageID
}
return c
}
func (c *rendererFollowCursor) noteResult(messageID string, sequence uint64) {
if _, ok := c.results[messageID]; !ok {
c.resultOrder = append(c.resultOrder, messageID)
}
c.results[messageID] = sequence
for len(c.resultOrder) > 192 {
delete(c.results, c.resultOrder[0])
c.resultOrder = c.resultOrder[1:]
}
}
func (c *rendererFollowCursor) accept(changes []transcript.Change) error {
ordered := slices.Clone(changes)
slices.SortFunc(ordered, func(a, b transcript.Change) int { return cmp.Compare(a.Revision, b.Revision) })
for _, change := range ordered {
if change.Revision <= c.revision {
continue
}
if change.ResetRequired || change.Revision != c.revision+1 {
return fmt.Errorf("revision_gap at revision %d", change.Revision)
}
if change.FirstSeq != 0 {
if change.FirstSeq != c.coverage+1 || change.CommitSeq < change.FirstSeq {
return fmt.Errorf("business_gap at revision %d: first=%d coverage=%d", change.Revision, change.FirstSeq, c.coverage)
}
c.coverage = change.CommitSeq
} else if change.CommitSeq != c.coverage {
return fmt.Errorf("frame_cut_mismatch at revision %d: commit=%d coverage=%d", change.Revision, change.CommitSeq, c.coverage)
}
e := change.Event
for _, record := range change.Records {
if record.MessageID != "" {
c.noteResult(record.MessageID, change.CommitSeq)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action == "begin" {
if e.MessageID == "" {
return fmt.Errorf("sampling_identity_missing at revision %d", change.Revision)
}
c.indexes[e.StreamAttempt.ID], c.attempts[e.StreamAttempt.ID] = 0, e.MessageID
}
if change.AttemptID != "" && change.ResultSeq == 0 && (e == nil || e.Kind != "stream_attempt") {
if c.indexes[change.AttemptID] != change.Index {
return fmt.Errorf("sampling_gap at revision %d", change.Revision)
}
c.indexes[change.AttemptID] = change.Index + 1
}
if change.ResultSeq != 0 && (change.ResultSeq > c.coverage || (change.ResultKind != "message/complete" && change.ResultKind != "message/interrupted")) {
return fmt.Errorf("settlement_not_committed at revision %d", change.Revision)
}
if change.ResultSeq != 0 {
message := c.attempts[change.AttemptID]
recorded, known := c.results[message]
if message == "" || e == nil || message != e.MessageID || known && recorded != change.ResultSeq {
return fmt.Errorf("settlement_identity_mismatch at revision %d", change.Revision)
}
}
if e != nil && e.Kind == "stream_attempt" && e.StreamAttempt != nil && e.StreamAttempt.Action != "begin" {
delete(c.indexes, e.StreamAttempt.ID)
delete(c.attempts, e.StreamAttempt.ID)
}
if e != nil && (e.Kind == "text" || e.Kind == "reasoning") {
c.deltas[e.Kind] += e.Text
}
c.revision = change.Revision
}
return nil
}
// legacyStreamProvider answers each turn from a script: a streamed reply, a
// tool-call-only round followed by a reply, or a stream that stalls until the
// request is cancelled.
type legacyStreamProvider struct {
mu sync.Mutex
mode string
toolSent bool
}
func (p *legacyStreamProvider) set(mode string) {
p.mu.Lock()
p.mode, p.toolSent = mode, false
p.mu.Unlock()
}
func (p *legacyStreamProvider) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.mu.Lock()
mode, toolRound := p.mode, p.mode == "tool" && !p.toolSent
if toolRound {
p.toolSent = true
}
p.mu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
flusher, _ := w.(http.Flusher)
send := func(delta string) {
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{`+delta+`}}]}`+"\n\n")
flusher.Flush()
}
switch {
case toolRound:
send(`"tool_calls":[{"index":0,"id":"call_read","type":"function","function":{"name":"read_file","arguments":"{\"path\":\"note.txt\"}"}}]`)
_, _ = io.WriteString(w, `data: {"choices":[{"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n")
case mode == "stall":
send(`"reasoning_content":"pondering"`)
<-r.Context().Done()
return
default:
for _, chunk := range []string{`"reasoning_content":"weigh "`, `"reasoning_content":"it"`, `"content":"stream"`, `"content":"ed"`} {
send(chunk)
time.Sleep(20 * time.Millisecond)
}
}
_, _ = io.WriteString(w, "data: [DONE]\n\n")
}
type legacyFollowTurn struct{ name, mode, reasoning, text string }
func TestLegacyTopicTranscriptFollowStreamsDeltasTheRendererAccepts(t *testing.T) {
reply := legacyFollowTurn{"streamed reply", "stream", "weigh it", "streamed"}
for _, first := range []legacyFollowTurn{
reply,
{"tool-call-only round", "tool", "weigh it", "streamed"},
{"cancelled round", "stall", "pondering", ""},
} {
t.Run(first.name, func(t *testing.T) { followLegacyTopicTurns(t, first, reply) })
}
}
func followLegacyTopicTurns(t *testing.T, turns ...legacyFollowTurn) {
isolateDesktopUserDirs(t)
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
script := &legacyStreamProvider{}
stub := httptest.NewServer(script)
t.Cleanup(stub.Close)
cfg := config.Default()
cfg.DefaultModel = "test/test-model"
cfg.Desktop.ProviderAccess = []string{"test"}
cfg.Providers = []config.ProviderEntry{{Name: "test", Kind: "openai", BaseURL: stub.URL, Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"}}
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
t.Fatal(err)
}
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "note.txt"), []byte("note"), 0o644); err != nil {
t.Fatal(err)
}
if err := addProject(root, "Project"); err != nil {
t.Fatal(err)
}
dir := desktopSessionDir(root)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
path := writeTopicSession(t, dir, "legacy.jsonl", "legacy-topic", "Legacy", root)
app := NewApp()
app.ctx = context.Background()
app.readyHook = func() {}
t.Cleanup(func() { app.shutdown(context.Background()) })
events := newActivationEventRecorder(app)
ticket, err := app.StartTopicActivation(TopicActivationRequest{Scope: "project", WorkspaceRoot: root, TopicID: "legacy-topic", SessionPath: path, RequestID: "open"})
if err != nil {
t.Fatal(err)
}
if ev := events.waitFor(t, func(ev TopicActivationEvent) bool {
return ev.RequestID == "open" && (ev.Phase == "ready" || ev.Phase == "failed")
}); ev.Phase != "ready" {
t.Fatalf("activation failed: %+v", ev)
}
base, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{})
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
_, _ = app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, Close: true})
})
if base.StorageBackend != "legacy" {
t.Fatalf("fixture must follow a legacy-backed session, got %q", base.StorageBackend)
}
cursor := newRendererFollowCursor(base.Snapshot)
for _, turn := range turns {
script.set(turn.mode)
clear(cursor.deltas)
if err := app.SubmitToTab(ticket.TabID, turn.name); err != nil {
t.Fatal(err)
}
cancelled := false
deadline := time.Now().Add(15 * time.Second)
for done := false; !done; {
if time.Now().After(deadline) {
t.Fatalf("%s did not finish", turn.name)
}
resp, err := app.TranscriptFollowForTab(ticket.TabID, transcript.FollowRequest{Subscription: base.Subscription, AfterRevision: cursor.revision})
if err != nil {
t.Fatal(err)
}
if resp.ResetRequired {
t.Fatalf("%s: follower was reset", turn.name)
}
if err := cursor.accept(resp.Changes); err != nil {
t.Fatalf("%s: renderer would discard the follow and re-baseline: %v", turn.name, err)
}
for _, change := range resp.Changes {
done = done || change.Event != nil && change.Event.Kind == "turn_done"
}
if turn.mode == "stall" && !cancelled && cursor.deltas["reasoning"] != "" {
app.CancelTab(ticket.TabID)
cancelled = true
}
}
if cursor.deltas["reasoning"] != turn.reasoning || cursor.deltas["text"] != turn.text {
t.Fatalf("%s: renderer received deltas %+v", turn.name, cursor.deltas)
}
}
}
+81 -59
View File
@@ -68,14 +68,15 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
return out
}
base, err := workspaceBaseFromRoot(workspaceRoot)
repo, err := workspaceRepo(workspaceRoot, ctrl)
if err != nil {
out.GitAvailable = false
out.GitErr = err.Error()
return out
}
base := repo.Dir
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
changes := map[string]*workspaceChangeAccumulator{}
add := func(path string) *workspaceChangeAccumulator {
@@ -108,7 +109,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
}
}
gitEntries, gitErr := workspaceGitStatusContext(ctx, base)
gitEntries, gitErr := workspaceGitStatusContext(ctx, repo)
if gitErr != nil {
out.GitAvailable = false
out.GitErr = gitErr.Error()
@@ -153,7 +154,7 @@ func (a *App) workspaceChanges(ctx context.Context, tabID string) WorkspaceChang
return strings.ToLower(a.Path) < strings.ToLower(b.Path)
})
if out.GitAvailable {
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
}
return out
}
@@ -173,13 +174,13 @@ func (a *App) workspaceChangesTarget(tabID string) (string, control.SessionAPI,
return tab.WorkspaceRoot, tab.Ctrl, true
}
func (a *App) workspaceBaseForTab(tabID string) (string, error) {
func (a *App) workspaceRepoForTab(tabID string) (gitcmd.Repo, error) {
tabID = strings.TrimSpace(tabID)
workspaceRoot, _, ok := a.workspaceChangesTarget(tabID)
workspaceRoot, ctrl, ok := a.workspaceChangesTarget(tabID)
if !ok {
return "", fmt.Errorf("tab %q not found", tabID)
return gitcmd.Repo{}, fmt.Errorf("tab %q not found", tabID)
}
return workspaceBaseFromRoot(workspaceRoot)
return workspaceRepo(workspaceRoot, ctrl)
}
// WorkspaceChangeDetail returns the current patch for one file in the
@@ -191,10 +192,11 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
if !ok {
return WorkspaceChangeDetailView{}, fmt.Errorf("tab %q not found", tabID)
}
base, err := workspaceBaseFromRoot(workspaceRoot)
repo, err := workspaceRepo(workspaceRoot, ctrl)
if err != nil {
return WorkspaceChangeDetailView{}, err
}
base := repo.Dir
rel := normalizeWorkspaceRelPath(base, path)
if rel == "" {
return WorkspaceChangeDetailView{}, os.ErrInvalid
@@ -206,7 +208,7 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
return WorkspaceChangeDetailView{}, os.ErrInvalid
}
if detail, found := workspaceGitChangeDetail(base, rel); found {
if detail, found := workspaceGitChangeDetail(repo, rel); found {
return detail, nil
}
if ctrl != nil {
@@ -217,8 +219,9 @@ func (a *App) WorkspaceChangeDetail(tabID, path string) (WorkspaceChangeDetailVi
return WorkspaceChangeDetailView{}, nil
}
func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool) {
entries, err := workspaceGitStatus(base)
func workspaceGitChangeDetail(repo gitcmd.Repo, rel string) (WorkspaceChangeDetailView, bool) {
base := repo.Dir
entries, err := workspaceGitStatus(repo)
if err != nil {
return WorkspaceChangeDetailView{}, false
}
@@ -235,7 +238,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
// Untracked files are omitted by git diff. In an unborn repository HEAD is
// absent as well, so synthesize the same create/delete patch from disk.
if entry.Status == "??" || !workspaceGitHasHead(base) {
if entry.Status == "??" || !workspaceGitHasHead(repo) {
detail, err := workspaceCheckpointChangeDetail(base, rel, nil)
if err != nil {
return WorkspaceChangeDetailView{}, false
@@ -248,7 +251,7 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
if entry.OldPath != "" && entry.OldPath != rel {
args = append(args, filepath.FromSlash(entry.OldPath))
}
raw, truncated, err := workspaceGitDiffOutput(args...)
raw, truncated, err := workspaceGitDiffOutput(repo, args...)
if err != nil {
return WorkspaceChangeDetailView{}, false
}
@@ -264,12 +267,12 @@ func workspaceGitChangeDetail(base, rel string) (WorkspaceChangeDetailView, bool
return WorkspaceChangeDetailView{Diff: &patch, Source: "git", Added: added, Removed: removed, Binary: binary}, true
}
func workspaceGitHasHead(base string) bool {
return workspaceGit("-C", base, "rev-parse", "--verify", "HEAD").Run() == nil
func workspaceGitHasHead(repo gitcmd.Repo) bool {
return workspaceGit(repo, "-C", repo.Dir, "rev-parse", "--verify", "HEAD").Run() == nil
}
func workspaceGitDiffOutput(args ...string) ([]byte, bool, error) {
cmd := workspaceGit(args...)
func workspaceGitDiffOutput(repo gitcmd.Repo, args ...string) ([]byte, bool, error) {
cmd := workspaceGit(repo, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, false, err
@@ -381,43 +384,44 @@ func tallyUnifiedPatch(patch string) (added, removed int) {
return added, removed
}
// workspaceGit builds a console-hidden git probe. gitcmd supplies the shared
// invocation baseline: CREATE_NO_WINDOW so git's own children inherit the
// invisible console, and the config overrides that keep a probe from spawning a
// background daemon that opens a console of its own (#3906).
func workspaceGit(args ...string) *exec.Cmd {
return workspaceGitCommand(context.Background(), args...)
// workspaceGit builds a console-hidden git probe against repo's pinned
// identity. gitcmd supplies the shared invocation baseline: CREATE_NO_WINDOW so
// git's own children inherit the invisible console, and the config overrides
// that keep a probe from spawning a background daemon (#3906).
func workspaceGit(repo gitcmd.Repo, args ...string) *exec.Cmd {
return workspaceGitCommand(context.Background(), repo, args...)
}
func workspaceGitCommand(ctx context.Context, args ...string) *exec.Cmd {
return gitcmd.Command(ctx, "", args...)
func workspaceGitCommand(ctx context.Context, repo gitcmd.Repo, args ...string) *exec.Cmd {
return repo.Command(ctx, args...)
}
func workspaceGitOutputWithTimeout(timeout time.Duration, args ...string) ([]byte, error) {
func workspaceGitOutputWithTimeout(timeout time.Duration, repo gitcmd.Repo, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return workspaceGitCommand(ctx, args...).Output()
return workspaceGitCommand(ctx, repo, args...).Output()
}
func workspaceGitStatus(base string) ([]gitStatusEntry, error) {
func workspaceGitStatus(repo gitcmd.Repo) ([]gitStatusEntry, error) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
return workspaceGitStatusContext(ctx, base)
return workspaceGitStatusContext(ctx, repo)
}
func workspaceGitStatusContext(ctx context.Context, base string) ([]gitStatusEntry, error) {
func workspaceGitStatusContext(ctx context.Context, repo gitcmd.Repo) ([]gitStatusEntry, error) {
base := repo.Dir
// Git's porcelain paths are repository-relative even when -C points at a
// subdirectory. Derive the textual repository prefix from Git itself instead
// of comparing absolute paths: Windows may spell the same directory once as
// an 8.3 path and once as a long path, which makes filepath.Rel reject every
// otherwise valid status entry.
prefixCmd := workspaceGitCommand(ctx, "-C", base, "rev-parse", "--show-prefix")
prefixCmd := workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--show-prefix")
prefixRaw, err := prefixCmd.Output()
if err != nil {
return nil, err
}
prefix := strings.TrimSpace(string(prefixRaw))
cmd := workspaceGitCommand(ctx, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
cmd := workspaceGitCommand(ctx, repo, "-C", base, "status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".")
raw, err := cmd.Output()
if err != nil {
return nil, err
@@ -492,7 +496,7 @@ func workspaceRelPathFromGitPrefix(base, prefix, path string) string {
// metadata is less harmful than blocking tab activation or hydration. Workflows
// that need an immediate git read, such as WorkspaceChanges, should call
// workspaceGitBranch directly.
func workspaceGitBranchForMeta(base string) string {
func workspaceGitBranchForMeta(base string, repo gitcmd.Repo) string {
key := filepath.Clean(base)
now := time.Now()
@@ -507,7 +511,7 @@ func workspaceGitBranchForMeta(base string) string {
cached.request = new(int)
workspaceGitBranchCache.entries[key] = cached
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base, cached.request)
go refreshWorkspaceGitBranchForMeta(key, base, repo, cached.request)
return branch
}
@@ -519,11 +523,13 @@ func workspaceGitBranchForMeta(base string) string {
}
workspaceGitBranchCache.Unlock()
go refreshWorkspaceGitBranchForMeta(key, base, request)
go refreshWorkspaceGitBranchForMeta(key, base, repo, request)
return ""
}
func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
// refreshWorkspaceGitBranchForMeta reads through repo, the tab session's
// identity; a tab with no session yet resolves base as opening it would.
func refreshWorkspaceGitBranchForMeta(key, base string, repo gitcmd.Repo, request *int) {
branch := ""
// Store via defer so the refreshing flag is always cleared, even when the
// probe panics or exits the goroutine early; otherwise the entry would stay
@@ -545,21 +551,24 @@ func refreshWorkspaceGitBranchForMeta(key, base string, request *int) {
workspaceGitBranchCache.entries[key] = workspaceGitBranchCacheEntry{branch: branch, expires: storeNow.Add(workspaceGitBranchCacheTTL)}
}()
branch = workspaceGitBranchForMetaProbe(base)
if repo.Dir == "" {
repo = openWorkspaceRepo(base)
}
branch = workspaceGitBranchForMetaProbe(repo)
}
// workspaceGitBranch returns the current git branch name for the repo rooted
// at base, or an empty string when base is not inside a git repository or when
// git is unavailable.
func workspaceGitBranch(base string) string {
// workspaceGitBranch returns the current git branch name of repo, or an empty
// string when it names no git repository or git is unavailable.
func workspaceGitBranch(repo gitcmd.Repo) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
branch, _ := workspaceGitBranchContext(ctx, base)
branch, _ := workspaceGitBranchContext(ctx, repo)
return branch
}
func workspaceGitBranchContext(ctx context.Context, base string) (string, error) {
raw, err := workspaceGitCommand(ctx, "-C", base, "branch", "--show-current").Output()
func workspaceGitBranchContext(ctx context.Context, repo gitcmd.Repo) (string, error) {
base := repo.Dir
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "branch", "--show-current").Output()
if err != nil {
return "", err
}
@@ -567,7 +576,7 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
return branch, nil
}
raw, err = workspaceGitCommand(ctx, "-C", base, "rev-parse", "--short", "HEAD").Output()
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "rev-parse", "--short", "HEAD").Output()
if err != nil {
return "", err
}
@@ -580,19 +589,19 @@ func workspaceGitBranchContext(ctx context.Context, base string) (string, error)
// GitBranches returns all local git branches for the active workspace's repo.
func (a *App) GitBranches() ([]string, error) {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return nil, err
}
return workspaceLocalBranches(base)
return workspaceLocalBranches(repo)
}
func (a *App) GitCheckout(branch string) error {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return err
}
return workspaceCheckoutBranch(base, branch, false)
return workspaceCheckoutBranch(repo, branch, false)
}
const gitRefInvalidChars = " ~^:?*[\\" + "\t\n"
@@ -607,11 +616,11 @@ func validGitBranchName(name string) bool {
}
func (a *App) GitCreateBranch(name string) error {
base, err := a.activeWorkspaceBase()
repo, err := a.workspaceRepoForTab("")
if err != nil {
return err
}
return workspaceCheckoutBranch(base, name, true)
return workspaceCheckoutBranch(repo, name, true)
}
type GitCommitView struct {
@@ -627,17 +636,18 @@ type GitCommitDetailView struct {
}
func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, error) {
base, err := a.workspaceBaseForTab(tabID)
repo, err := a.workspaceRepoForTab(tabID)
if err != nil {
return nil, err
}
base := repo.Dir
args := []string{"-C", base, "log", "--pretty=format:%H%x00%an%x00%ad%x00%s", "-z", "-n", "100"}
if path != "" {
args = append(args, "--", path)
}
cmd := workspaceGit(args...)
cmd := workspaceGit(repo, args...)
raw, err := cmd.Output()
if err != nil {
return nil, err
@@ -658,27 +668,28 @@ func (a *App) WorkspaceGitHistory(tabID string, path string) ([]GitCommitView, e
}
func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (GitCommitDetailView, error) {
base, err := a.workspaceBaseForTab(tabID)
repo, err := a.workspaceRepoForTab(tabID)
if err != nil {
return GitCommitDetailView{}, err
}
base := repo.Dir
if path != "" {
// Single file diff
cmd := workspaceGit("-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
cmd := workspaceGit(repo, "-C", base, "show", "--relative", "--pretty=format:", "--patch", hash, "--", path)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
}
diffStr := strings.TrimSpace(string(raw))
return GitCommitDetailView{Diff: &diffStr}, nil
}
// Project level: list of files changed
cmd := workspaceGit("-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
cmd := workspaceGit(repo, "-C", base, "diff-tree", "--relative", "--no-commit-id", "--name-only", "-z", "-r", hash)
raw, err := cmd.Output()
if err != nil {
return GitCommitDetailView{}, err
return GitCommitDetailView{}, notLocalOr(repo, hash, err)
}
var files []string
@@ -689,3 +700,14 @@ func (a *App) WorkspaceGitCommitDetail(tabID string, hash string, path string) (
}
return GitCommitDetailView{Files: files}, nil
}
// notLocalOr names a failure caused by objects a partial clone never fetched —
// host git does not fetch them — and passes any other failure through.
func notLocalOr(repo gitcmd.Repo, hash string, err error) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if repo.ObjectsMissing(ctx, hash, hash+"^") {
return fmt.Errorf("commit %s: %w", hash, gitcmd.ErrObjectNotLocal)
}
return err
}
+17 -14
View File
@@ -9,6 +9,8 @@ import (
"sync"
"testing"
"time"
"reasonix/internal/gitcmd"
)
// The probe must not spawn a background daemon that opens a console of its own
@@ -17,14 +19,15 @@ import (
// it a second time here only guaranteed this test would break whenever the
// baseline gained an entry.
func TestWorkspaceGitDisablesDaemonSpawns(t *testing.T) {
cmd := workspaceGit("-C", "repo", "status", "--porcelain=v1")
for _, want := range []string{"core.fsmonitor=false", "maintenance.auto=false"} {
cmd := workspaceGit(gitcmd.Repo{}, "-C", "repo", "status", "--porcelain=v1")
for _, want := range []string{"core.fsmonitor=", "maintenance.auto=false"} {
if !hasGitConfigArg(cmd.Args, want) {
t.Fatalf("args = %v, want -c %s", cmd.Args, want)
}
}
if tail := cmd.Args[len(cmd.Args)-4:]; !slices.Equal(tail, []string{"-C", "repo", "status", "--porcelain=v1"}) {
t.Fatalf("args = %v, want the caller's arguments last", cmd.Args)
at := slices.Index(cmd.Args, "-C")
if at < 0 || !slices.Equal(cmd.Args[at:at+3], []string{"-C", "repo", "status"}) || cmd.Args[len(cmd.Args)-1] != "--porcelain=v1" {
t.Fatalf("args = %v, want the caller's arguments last and in order", cmd.Args)
}
if runtime.GOOS == "windows" && cmd.SysProcAttr == nil {
t.Fatal("workspaceGit must hide the console window on Windows")
@@ -61,7 +64,7 @@ func TestWorkspaceGitBranch(t *testing.T) {
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/status")
if got := workspaceGitBranch(repo); got != "feature/status" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/status" {
t.Fatalf("branch = %q, want feature/status", got)
}
}
@@ -87,11 +90,11 @@ func TestWorkspaceGitBranchReflectsImmediateCheckout(t *testing.T) {
runGit(t, "init")
runGit(t, "checkout", "-b", "feature/one")
if got := workspaceGitBranch(repo); got != "feature/one" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/one" {
t.Fatalf("branch before checkout = %q, want feature/one", got)
}
runGit(t, "checkout", "-b", "feature/two")
if got := workspaceGitBranch(repo); got != "feature/two" {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "feature/two" {
t.Fatalf("branch after checkout = %q, want feature/two", got)
}
}
@@ -125,13 +128,13 @@ func TestWorkspaceGitBranchDetachedHead(t *testing.T) {
short := gitOutput(t, "rev-parse", "--short", "HEAD")
runGit(t, "checkout", "--detach", "HEAD")
if got := workspaceGitBranch(repo); got != "@"+short {
if got := workspaceGitBranch(openWorkspaceRepo(repo)); got != "@"+short {
t.Fatalf("branch = %q, want @%s", got, short)
}
}
func TestWorkspaceGitBranchNonGitDirectory(t *testing.T) {
if got := workspaceGitBranch(t.TempDir()); got != "" {
if got := workspaceGitBranch(openWorkspaceRepo(t.TempDir())); got != "" {
t.Fatalf("branch = %q, want empty", got)
}
}
@@ -143,7 +146,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
close(started)
<-release
return "feature/async"
@@ -153,7 +156,7 @@ func TestWorkspaceGitBranchForMetaDoesNotBlockOnColdProbe(t *testing.T) {
workspaceGitBranchForMetaProbe = origProbe
}()
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe"); got != "" {
if got := workspaceGitBranchForMeta("/tmp/reasonix-cold-probe", gitcmd.Repo{}); got != "" {
t.Fatalf("cold branch = %q, want empty while async refresh runs", got)
}
// The probe is still blocked here, so returning is the deterministic proof
@@ -182,7 +185,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
release := make(chan struct{})
var releaseOnce sync.Once
releaseProbe := func() { releaseOnce.Do(func() { close(release) }) }
workspaceGitBranchForMetaProbe = func(string) string {
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string {
close(started)
<-release
return "feature/fresh"
@@ -192,7 +195,7 @@ func TestWorkspaceGitBranchForMetaReturnsStaleDuringRefresh(t *testing.T) {
workspaceGitBranchForMetaProbe = origProbe
}()
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe"); got != "feature/stale" {
if got := workspaceGitBranchForMeta("/tmp/reasonix-stale-probe", gitcmd.Repo{}); got != "feature/stale" {
t.Fatalf("stale branch = %q, want feature/stale", got)
}
// The probe is still blocked here, so returning the stale value proves that
@@ -218,7 +221,7 @@ func eventuallyBranchForMeta(t *testing.T, base, want string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for time.Now().Before(deadline) {
if got := workspaceGitBranchForMeta(base); got == want {
if got := workspaceGitBranchForMeta(base, gitcmd.Repo{}); got == want {
return
}
time.Sleep(10 * time.Millisecond)
+27 -22
View File
@@ -7,61 +7,63 @@ import (
"path/filepath"
"strings"
"time"
"reasonix/internal/gitcmd"
)
func (a *App) gitWorkspaceBaseForTab(tabID, expectedRoot string) (string, error) {
func (a *App) gitWorkspaceRepoForTab(tabID, expectedRoot string) (gitcmd.Repo, error) {
tabID = strings.TrimSpace(tabID)
if tabID == "" {
return "", fmt.Errorf("workspace tab is required")
return gitcmd.Repo{}, fmt.Errorf("workspace tab is required")
}
root, _, ok := a.workspaceChangesTarget(tabID)
root, ctrl, ok := a.workspaceChangesTarget(tabID)
if !ok || !filepath.IsAbs(root) {
return "", fmt.Errorf("workspace tab %q is unavailable", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q is unavailable", tabID)
}
if !filepath.IsAbs(expectedRoot) || !sameProjectRoot(root, expectedRoot) {
return "", fmt.Errorf("workspace tab %q has changed project", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q has changed project", tabID)
}
if info, err := os.Stat(root); err != nil || !info.IsDir() {
return "", fmt.Errorf("workspace tab %q directory is unavailable", tabID)
return gitcmd.Repo{}, fmt.Errorf("workspace tab %q directory is unavailable", tabID)
}
return workspaceBaseFromRoot(root)
return workspaceRepo(root, ctrl)
}
func (a *App) GitBranchesForTab(tabID, workspaceRoot string) ([]string, error) {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return nil, err
}
return workspaceLocalBranches(base)
return workspaceLocalBranches(repo)
}
func (a *App) GitCheckoutForTab(tabID, workspaceRoot, branch string) error {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return err
}
return workspaceCheckoutBranch(base, branch, false)
return workspaceCheckoutBranch(repo, branch, false)
}
func (a *App) GitCreateBranchForTab(tabID, workspaceRoot, name string) error {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return err
}
return workspaceCheckoutBranch(base, name, true)
return workspaceCheckoutBranch(repo, name, true)
}
// The launcher needs only Git totals, not session checkpoints or per-file views.
func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceChangesView, error) {
base, err := a.gitWorkspaceBaseForTab(tabID, workspaceRoot)
repo, err := a.gitWorkspaceRepoForTab(tabID, workspaceRoot)
if err != nil {
return WorkspaceChangesView{}, err
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
out := WorkspaceChangesView{Files: []WorkspaceChangeView{}, GitAvailable: true}
out.GitBranch, _ = workspaceGitBranchContext(ctx, base)
entries, err := workspaceGitStatusContext(ctx, base)
out.GitBranch, _ = workspaceGitBranchContext(ctx, repo)
entries, err := workspaceGitStatusContext(ctx, repo)
if err != nil {
out.GitAvailable, out.Incomplete, out.GitErr = false, true, err.Error()
return out, nil
@@ -72,19 +74,22 @@ func (a *App) WorkspaceGitStatsForTab(tabID, workspaceRoot string) (WorkspaceCha
untracked = append(untracked, entry.Path)
}
}
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, base, untracked)
out.Added, out.Removed, out.Incomplete = workspaceGitDiffTally(ctx, repo, untracked)
return out, nil
}
func workspaceLocalBranches(base string) ([]string, error) {
raw, err := workspaceGitOutputWithTimeout(3*time.Second, "-C", base, "branch", "--format=%(refname:short)")
func workspaceLocalBranches(repo gitcmd.Repo) ([]string, error) {
raw, err := workspaceGitOutputWithTimeout(3*time.Second, repo, "-C", repo.Dir, "branch", "--format=%(refname:short)")
if err != nil {
return nil, err
}
return append([]string{}, strings.FieldsFunc(strings.TrimSpace(string(raw)), func(r rune) bool { return r == '\n' })...), nil
}
func workspaceCheckoutBranch(base, name string, create bool) error {
// workspaceCheckoutBranch switches repo's branch. Submodules are not updated
// (gitcmd pins submodule.recurse off for checkout).
func workspaceCheckoutBranch(repo gitcmd.Repo, name string, create bool) error {
base := repo.Dir
name = strings.TrimSpace(name)
if !validGitBranchName(name) {
return fmt.Errorf("invalid branch name %q", name)
@@ -96,11 +101,11 @@ func workspaceCheckoutBranch(base, name string, create bool) error {
args = append(args, name)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
out, err := workspaceGitCommand(ctx, args...).CombinedOutput()
out, err := workspaceGitCommand(ctx, repo, args...).CombinedOutput()
if err != nil {
return fmt.Errorf("git checkout: %w: %s", err, strings.TrimSpace(string(out)))
}
branch, _ := workspaceGitBranchContext(ctx, base)
branch, _ := workspaceGitBranchContext(ctx, repo)
workspaceGitBranchCache.Lock()
if branch == "" {
delete(workspaceGitBranchCache.entries, filepath.Clean(base))
+6 -3
View File
@@ -3,6 +3,8 @@ package main
import (
"path/filepath"
"testing"
"reasonix/internal/gitcmd"
)
func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
@@ -14,11 +16,12 @@ func TestWorkspaceGitCheckoutInvalidatesInFlightBranchProbe(t *testing.T) {
workspaceGitBranchCache.Unlock()
started, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{})
original := workspaceGitBranchForMetaProbe
workspaceGitBranchForMetaProbe = func(string) string { close(started); <-release; return "main" }
workspaceGitBranchForMetaProbe = func(gitcmd.Repo) string { close(started); <-release; return "main" }
defer func() { workspaceGitBranchForMetaProbe = original }()
go func() { refreshWorkspaceGitBranchForMeta(key, base, request); close(done) }()
repo := openWorkspaceRepo(base)
go func() { refreshWorkspaceGitBranchForMeta(key, base, repo, request); close(done) }()
<-started
err := workspaceCheckoutBranch(base, "shared", false)
err := workspaceCheckoutBranch(repo, "shared", false)
close(release)
<-done
if err != nil {
+7 -5
View File
@@ -7,6 +7,8 @@ import (
"slices"
"strings"
"testing"
"reasonix/internal/gitcmd"
)
func gitScopeRepo(t *testing.T) string {
@@ -17,7 +19,7 @@ func gitScopeRepo(t *testing.T) string {
{"-c", "user.name=Test", "-c", "[email protected]", "commit", "--allow-empty", "-m", "init"},
{"branch", "shared"},
} {
if out, err := workspaceGit(append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
if out, err := gitcmd.Command(context.Background(), root, args...).CombinedOutput(); err != nil {
t.Fatalf("git fixture: %v: %s", err, out)
}
}
@@ -34,7 +36,7 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
if err != nil || !slices.Contains(list, "shared") {
t.Fatalf("branches: %v, %v", list, err)
}
base, err := a.gitWorkspaceBaseForTab("a", rootA)
base, err := a.gitWorkspaceRepoForTab("a", rootA)
if err != nil {
t.Fatal(err)
}
@@ -45,13 +47,13 @@ func TestWorkspaceGitBranchScope(t *testing.T) {
if err := workspaceCheckoutBranch(base, "shared", false); err != nil {
t.Fatal(err)
}
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "main" {
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "main" {
t.Fatal("checkout changed the replacement workspace")
}
if err := a.GitCreateBranchForTab("b", rootB, "new-branch"); err != nil {
t.Fatal(err)
}
if workspaceGitBranch(rootA) != "shared" || workspaceGitBranch(rootB) != "new-branch" {
if workspaceGitBranch(openWorkspaceRepo(rootA)) != "shared" || workspaceGitBranch(openWorkspaceRepo(rootB)) != "new-branch" {
t.Fatal("create crossed project boundaries")
}
if err := a.GitCheckoutForTab("a", rootB, "--detach"); err == nil {
@@ -117,7 +119,7 @@ func TestWorkspaceTallyBudgetAndFileKinds(t *testing.T) {
for i := range paths {
paths[i] = "large"
}
added, _, partial := workspaceGitDiffTally(context.Background(), base, paths)
added, _, partial := workspaceGitDiffTally(context.Background(), openWorkspaceRepo(base), paths)
if added != workspaceDiffTallyReadLimit/2 || !partial {
t.Fatalf("aggregate budget: %d, %v", added, partial)
}
+6 -3
View File
@@ -8,6 +8,8 @@ import (
"path/filepath"
"strconv"
"strings"
"reasonix/internal/gitcmd"
)
const (
@@ -15,11 +17,12 @@ const (
workspaceDiffTallyReadLimit = 16 << 20
)
func workspaceGitDiffTally(ctx context.Context, base string, untracked []string) (added, removed int, incomplete bool) {
raw, err := workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
func workspaceGitDiffTally(ctx context.Context, repo gitcmd.Repo, untracked []string) (added, removed int, incomplete bool) {
base := repo.Dir
raw, err := workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "HEAD", "--", ".").Output()
if err != nil && ctx.Err() == nil {
incomplete = true
raw, err = workspaceGitCommand(ctx, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
raw, err = workspaceGitCommand(ctx, repo, "-C", base, "diff", "--numstat", "--no-textconv", "--", ".").Output()
}
if err != nil {
return 0, 0, true
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"testing"
"reasonix/internal/gitcmd"
)
// partialClone returns a blob-filtered clone of a three-commit repository, so
// the blobs of every commit but the checked-out one are absent locally.
func partialClone(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
src := t.TempDir()
run := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
run(src, "init", "-q")
for _, body := range []string{"1\n", "2\n", "3\n"} {
if err := os.WriteFile(filepath.Join(src, "a.txt"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
run(src, "add", "a.txt")
run(src, "commit", "-q", "-m", body)
}
run(src, "config", "uploadpack.allowFilter", "true")
clone := filepath.Join(t.TempDir(), "partial")
run(src, "clone", "-q", "--filter=blob:none", "file://"+src, clone)
return clone
}
// A historical patch in a partial clone fails with an error naming the
// unfetched objects, which the commit-detail view can tell from other failures.
func TestCommitDetailInPartialCloneReportsObjectNotLocal(t *testing.T) {
clone := partialClone(t)
out, err := workspaceGit(openWorkspaceRepo(clone), "-C", clone, "show", "--pretty=format:", "--patch", "HEAD~1", "--", "a.txt").Output()
if err == nil {
t.Fatalf("show of an unfetched commit succeeded: %s", out)
}
if got := notLocalOr(openWorkspaceRepo(clone), "HEAD~1", err); !errors.Is(got, gitcmd.ErrObjectNotLocal) {
t.Fatalf("notLocalOr = %v, want ErrObjectNotLocal", got)
}
other := errors.New("other")
if got := notLocalOr(openWorkspaceRepo(clone), "no-such-rev", other); !errors.Is(got, other) {
t.Fatalf("notLocalOr(no-such-rev) = %v, want the original error passed through", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package main
import (
"context"
"path/filepath"
"time"
"reasonix/internal/control"
"reasonix/internal/gitcmd"
)
const workspaceRepoOpenLimit = 3 * time.Second
// sessionWorkspaceRepo is the git identity ctrl's session resolved for base
// when it opened, and whether that session holds one for base at all.
func sessionWorkspaceRepo(ctrl control.SessionAPI, base string) (gitcmd.Repo, bool) {
c, ok := ctrl.(interface{ WorkspaceRepo() gitcmd.Repo })
if !ok {
return gitcmd.Repo{}, false
}
repo := c.WorkspaceRepo()
return repo, repo.Dir != "" && filepath.Clean(repo.Dir) == filepath.Clean(base)
}
// workspaceRepo is the identity host git reads root through: the one its
// session opened with, or, for a root no session holds yet, the one it
// resolves to now, as opening it would.
func workspaceRepo(root string, ctrl control.SessionAPI) (gitcmd.Repo, error) {
base, err := workspaceBaseFromRoot(root)
if err != nil {
return gitcmd.Repo{}, err
}
if repo, ok := sessionWorkspaceRepo(ctrl, base); ok {
return repo, nil
}
return openWorkspaceRepo(base), nil
}
func openWorkspaceRepo(base string) gitcmd.Repo {
ctx, cancel := context.WithTimeout(context.Background(), workspaceRepoOpenLimit)
defer cancel()
repo, err := gitcmd.Open(ctx, base)
if err != nil {
return gitcmd.Repo{Dir: base}
}
return repo
}
// workspaceRepoForRoot is root's identity as a tab session holding it opened
// it, or as opening it now would.
func (a *App) workspaceRepoForRoot(root string) (gitcmd.Repo, error) {
var ctrl control.SessionAPI
a.mu.RLock()
for _, tab := range a.tabs {
if tab.Ctrl != nil && sameProjectRoot(tab.WorkspaceRoot, root) {
ctrl = tab.Ctrl
break
}
}
a.mu.RUnlock()
return workspaceRepo(root, ctrl)
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"reasonix/internal/control"
"reasonix/internal/event"
"reasonix/internal/gitcmd"
)
// The changes panel reads a tab through the identity its session resolved when
// it opened, even after the workspace — a subdirectory of that repository —
// gains its own .git whose local config names a filter.
func TestWorkspaceChangesReadTheSessionRepository(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("payload script is POSIX shell")
}
mono := t.TempDir()
pkg := filepath.Join(mono, "pkg")
git := func(dir string, args ...string) {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
if err := os.MkdirAll(pkg, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("a\n"), 0o644); err != nil {
t.Fatal(err)
}
git(mono, "init", "-q")
git(mono, "add", ".")
git(mono, "commit", "-qm", "base")
repo, err := gitcmd.Open(context.Background(), pkg)
if err != nil {
t.Fatal(err)
}
ctrl := control.New(control.Options{Sink: event.Discard, WorkspaceRoot: pkg, WorkspaceRepo: repo})
a := &App{tabs: map[string]*WorkspaceTab{"a": {WorkspaceRoot: pkg, Ctrl: ctrl}}}
marker := filepath.Join(t.TempDir(), "executed")
payload := filepath.Join(t.TempDir(), "payload.sh")
if err := os.WriteFile(payload, []byte("#!/bin/sh\necho ran >> '"+marker+"'\ncat\n"), 0o755); err != nil {
t.Fatal(err)
}
git(pkg, "init", "-q")
git(pkg, "config", "filter.pwn.clean", payload)
if err := os.WriteFile(filepath.Join(pkg, ".gitattributes"), []byte("f.txt filter=pwn\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pkg, "f.txt"), []byte("b\n"), 0o644); err != nil {
t.Fatal(err)
}
view := a.WorkspaceChanges("a")
var modified bool
for _, f := range view.Files {
modified = modified || (f.Path == "f.txt" && f.GitStatus == "M")
}
if !view.GitAvailable || !modified || view.Removed != 1 {
t.Fatalf("changes = %+v, want f.txt modified against the enclosing repository", view)
}
if _, err := a.WorkspaceChangeDetail("a", "f.txt"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("the changes panel ran the nested repository's filter")
}
}
+25 -4
View File
@@ -159,11 +159,32 @@ entirely of non-ASCII characters get a stable hash suffix such as
generated environment variable remains valid; for example, `9router` becomes
`CUSTOM_9ROUTER_API_KEY`.
The CLI custom-provider wizard uses this rule while building its draft. For example
The CLI custom-provider wizard uses this rule for its draft name. For example
`https://token.sensenova.cn/v1` creates provider name
`custom-token-sensenova-cn`, whose default key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`; commit then switches the saved connection
to its newly allocated private slot.
`custom-token-sensenova-cn`, whose draft key env is
`CUSTOM_TOKEN_SENSENOVA_CN_API_KEY`. Pressing Enter at the variable-name
prompt keeps that draft only until the key is saved; the saved connection then
uses a newly allocated private slot.
A variable name you type at that prompt in `reasonix setup` is kept, so scripts
can refer to a stable name, as long as saving under it changes nothing another
connection reads: no other provider, bot or remote-host setting in the config
reads it, the global `.env` holds no value (or cleared marker) for it, and the
environment Reasonix runs in does not already set it. Otherwise the wizard says
what holds the name and asks again; Enter falls back to a
private slot. If the name is claimed between the prompt and saving, the save is
refused and nothing is written.
Saving a new key later for a provider in the user config rewrites its
variable in place when that provider (or the set of providers the key is saved
for) is the only reader of it in the user config, both before and after the
edit, and the global `.env` already holds its value. A project that reads the
same name sees the new key, as it saw the old one. Providers declared in a
project `reasonix.toml` always get a private slot. The previous value is kept in the global `.env` under
a temporary variable until the config is published: a save that fails or is
interrupted puts it back, unless something else has written the variable since.
When another provider or setting also reads the variable, the new key goes to a
private slot as before and the shared variable is left unchanged.
Existing configs are not rewritten on upgrade. If an old custom provider already
uses `CUSTOM_API_KEY`, it will keep working with that key. If several old custom
+16 -2
View File
@@ -129,8 +129,22 @@ env 名,例如 `LOCAL_GATEWAY_API_KEY`;如果名称全部由中文等非 ASC
都共用 `CUSTOM_API_KEY`。如果名称以数字开头,则会添加 `CUSTOM_` 前缀以保证生成的
环境变量名合法;例如 `9router` 会生成 `CUSTOM_9ROUTER_API_KEY`。
CLI 的自定义 provider 向导在构建草稿时仍会先根据 base URL 生成 provider 名称,再套用
同一规则;提交时会把保存后的连接切换到新分配的独立槽位。
CLI 的自定义 provider 向导会先根据 base URL 生成 provider 名称,再套用同一规则得到草稿
变量名。在变量名提示处直接回车时,这个草稿名只在保存 key 之前使用;保存后连接会切换到
新分配的独立槽位。
在 `reasonix setup` 的这个提示处手动输入的变量名会被保留,方便脚本引用固定名称,前提是
写入它不会改变其他读取方拿到的值:配置里没有其他 provider、机器人或远程主机设置读取它,
全局 `.env` 里没有它的值(或清空标记),Reasonix 运行的环境变量里也没有设置它。否则向导
会说明是什么占用了它,并请你重新输入;直接回车则改用独立槽位。如果在提示之后、保存之前这个名字被占用,保存会被拒绝,
不写入任何内容。
之后再为用户配置里的 provider 保存新 key 时,如果这个 provider(或本次一起保存 key 的那组
provider)在用户配置里是该变量唯一的读取方(编辑前后都是),且全局 `.env` 里已有它的值,
Reasonix 会原地改写这个变量;读取同名变量的项目会像之前读到旧 key 一样读到新 key。项目
`reasonix.toml` 里声明的 provider 始终改用独立槽位。配置发布之前,旧值以一个临时变量名保存在全局 `.env` 里:
保存失败或中断时会写回旧值,除非这期间有别的写入方改过这个变量。如果还有其他 provider
或设置读取这个变量,新 key 仍写入独立槽位,共享变量保持不变。
升级时不会自动改写已有配置。旧配置中已经使用 `CUSTOM_API_KEY` 的自定义 provider 会继续
读取这个 key。若多个旧自定义 provider 已经意外共用了 `CUSTOM_API_KEY`,重新保存每个
+95 -2
View File
@@ -246,8 +246,30 @@ the terminal; stop it with Ctrl-C.
An explicit `reasonix web --auth none` disables the default token and should be
used only when the listener is intentionally trusted. `reasonix serve` keeps its
backward-compatible, config-driven `auth_mode = "none"` default on
`127.0.0.1:8787`. If you bind Serve outside loopback, expose it through a tunnel,
or put it behind a reverse proxy, enable authentication before sharing the URL:
`127.0.0.1:8787`.
Without authentication (`auth_mode = "none"`, the `serve` default) reads stay
open on the listener, but every state-changing request, approvals included,
needs the launch token:
- Serve writes the token to a 0600 file under `<Reasonix home>/remote/` and
prints only its path next to an `approvals:` link; append
`#token=<file contents>` to open it in a browser. A managed launch with
`--token-file` names that file instead. Token mode prints its `share:` link
the same way.
- Send it as `Authorization: Bearer <token>`, or open the link once so the page
sets its cookie. Without it the request answers 403 `launch_token_required`.
- Prefer `--token-file` over `--token`: argv is visible to other processes,
sandboxed ones included. A plaintext `[serve].token` in the global
config is readable from inside the sandbox; keep the secret in a file.
- On macOS and Linux the OS sandbox denies the remote state directory and any
`--token-file`. Windows has no bash sandbox, so there nothing keeps an agent
command from reading the file.
- `[serve]` is read from the user config only; a project `reasonix.toml`
cannot set it.
If you bind Serve outside loopback, expose it through a tunnel, or put it behind
a reverse proxy, enable authentication before sharing the URL:
```bash
reasonix serve --auth token
@@ -723,6 +745,77 @@ it has no OS-level shell sandbox, and denying the current user would also deny
the host settings process. Project `.env` files keep their existing
workspace-scoped behavior.
**Git metadata is host-protected.** Inside the bash sandbox, the Git
configuration and hooks of the workspace repository stay read-only, because the
host's own git reads them.
The repository is the one git itself discovers from each writable root. A
`.git` file is followed to the gitdir it names the way git resolves it,
relative to the file with symlinks followed, so the protection lands on what
git will read. Protected:
- `.git` itself, the gitdir, the common dir and every symlink on the way to
them: none can be removed, renamed or replaced by a symlink.
- `config`, `config.worktree`, `commondir` and `hooks/` of the gitdir and of
the common dir.
- `config`, `config.worktree` and `commondir` of each existing `worktrees/*`
entry, and `config` and `config.worktree` of entries created later.
- `config`, `config.worktree`, `commondir` and `hooks/` of each submodule
gitdir under `modules/`, existing or created later.
Everything else under `.git` (objects, refs, index, logs, lock files) stays
writable, so add, commit, branch, checkout, merge, rebase, stash, tag and
worktree creation keep working. These operations change:
| Operation | In the sandbox |
| --- | --- |
| `git config` without `--global`, `git remote add` / `set-url`, `git branch -m`, `git submodule init`, `git submodule update --init`, `git sparse-checkout init`, `git maintenance register`, `git init` in an existing repository | Fails |
| Installing a hook into `.git/hooks` | Fails |
| `git worktree remove` / `prune` of a linked worktree that existed before the command | Fails; one added in the same command can be removed |
| Cloning a new submodule (`git submodule add`, `git submodule update` for one not yet cloned) | Fails on macOS; on Linux the clone lands but its config entry does not |
| `git branch --set-upstream-to`, `git checkout --track`, `git push -u` | Reports the refused write but exits 0; no upstream is recorded |
To add or initialise submodules, run it outside the sandbox (in a terminal, or through an approved danger-full-access retry); updating submodules that are
already cloned works inside it.
When a command's output names one of these paths, the bash result says so with
`sandbox.git_metadata_protected`, also when git exited 0. `additional_write_dirs` cannot grant it.
A protected file that already has another hard link refuses every confined
command with `sandbox.git_metadata_linked`, because a write through the other
name would reach it; the user removes that link outside the sandbox.
Limits:
- On Linux, bubblewrap can only mount over paths that exist and cannot pin a
symlink. Creating an absent `commondir`, `config.worktree` or hooks
directory, or swapping a symlink on the `gitdir:` path, is not stopped there.
- Closing that on Linux is the host's side: its own git has to pin its git and
common directories instead of rediscovering them.
- Existing worktree and submodule gitdirs get exact rules, up to 128 each on
macOS and 512 on Linux.
- On macOS, patterns cover the rest and gitdirs created later. They skip
`refs/` and `logs/`, so a branch or tag named `config` or `hooks` stays
writable, but a new submodule named `hooks` is protected whole.
- On macOS with more than 128 worktrees, `git worktree add` fails; past 128
submodules each command starts about 0.1 s slower.
- On Linux, past 512 gitdirs the whole `worktrees/` or `modules/` directory is
mounted read-only, and a command can cause that by planting `HEAD` files.
A command can likewise plant a submodule gitdir with a hard-linked config,
after which every confined command is refused until the user removes it.
- A repository created by a sandboxed command is protected from the next
command on.
- A `.git` made unrecognisable to git (for example a corrupted `HEAD`) sends
git's discovery further up.
- A repository nested in the workspace that is not a submodule gitdir under
`modules/` is not protected, including one a command creates and records as
a gitlink.
- The host can run such a repository's configuration through its gitlink
unless the host's own git excludes it.
- Hooks that `core.hooksPath` points outside `.git`, and files `include.path`
names, are ordinary workspace files.
- Windows has no shell sandbox, so none of this is enforced there.
**Session-private temporary directory.** Within one logical chat session, Bash
commands share a private temporary directory so consecutive calls can exchange
files through `$TMPDIR` (and, on Linux under bubblewrap, through literal
+65 -2
View File
@@ -220,8 +220,17 @@ reasonix web
显式传入 `reasonix web --auth none` 可以关闭默认 Token,只应在监听地址确定可信时使用。
`reasonix serve` 则保持向后兼容:默认监听 `127.0.0.1:8787`,认证模式仍由配置决定,空配置为
`auth_mode = "none"`。如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,
请先开启认证再分享 URL:
`auth_mode = "none"`。
未开启认证时读取接口保持开放,但所有会改变状态的请求(包括审批)都需要本次启动的令牌:
- serve 把令牌写进 `<Reasonix home>/remote/` 下权限 0600 的文件,终端只打印文件路径和 `approvals:` 链接;在链接后拼上 `#token=<文件内容>` 用浏览器打开。带 `--token-file` 的托管启动则打印该文件路径。token 模式的 `share:` 链接同样处理。
- 以 `Authorization: Bearer <token>` 发送,或打开一次链接让页面写入 Cookie;否则返回 403 `launch_token_required`。
- 优先用 `--token-file` 而不是 `--token`:命令行参数对其他进程可见,沙盒内的进程也能看到。全局配置里明文的 `[serve].token` 在沙盒内可读,密钥请放文件。
- macOS 和 Linux 的系统沙盒会拒绝读取该状态目录和 `--token-file`;Windows 没有 bash 沙盒,agent 命令可以读到该文件。
- `[serve]` 只从用户配置读取,项目里的 `reasonix.toml` 不能设置它。
如果要绑定到非 loopback 地址、通过 tunnel 暴露,或放到反向代理后面,请先开启认证再分享 URL:
```bash
reasonix serve --auth token
@@ -580,6 +589,60 @@ Reasonix 始终会从工具子进程环境中移除已保存的 provider 与 bot
因为 Windows 没有 OS 级 Shell 沙箱,而拒绝当前用户也会拒绝宿主设置进程。项目
`.env` 仍保持现有的 workspace 范围行为。
**Git 元数据由宿主保护。**Bash 沙盒内,工作区仓库的 Git 配置和钩子保持只读,
因为宿主自己的 git 会读取它们。
受保护的仓库是 git 自身从每个可写根发现的那个。`.git` 是文件时,按 git 的方式解析
它指向的 gitdir(相对该文件、跟随符号链接),保护落在 git 实际读取的位置。受保护:
- `.git` 本身、gitdir、公共目录及通往它们的每个符号链接:都不能被删除、改名或替换成符号链接。
- gitdir 与公共目录中的 `config`、`config.worktree`、`commondir` 和 `hooks/`。
- 已有 `worktrees/*` 条目的 `config`、`config.worktree`、`commondir`,以及之后新建条目的
`config` 和 `config.worktree`。
- `modules/` 下每个子模块 gitdir(已有的和之后新建的)的 `config`、`config.worktree`、
`commondir` 和 `hooks/`。
`.git` 下其余内容(objects、refs、index、logs、锁文件)仍可写,因此 add、commit、
branch、checkout、merge、rebase、stash、tag 和创建 worktree 照常工作。以下操作的
行为会变:
| 操作 | 沙盒内 |
| --- | --- |
| 不带 `--global` 的 `git config`、`git remote add` / `set-url`、`git branch -m`、`git submodule init`、`git submodule update --init`、`git sparse-checkout init`、`git maintenance register`、对已有仓库执行 `git init` | 失败 |
| 向 `.git/hooks` 安装钩子 | 失败 |
| 对命令开始前已存在的 linked worktree 执行 `git worktree remove` / `prune` | 失败;同一条命令里新建的可以删除 |
| 克隆新的子模块(`git submodule add`,或对尚未克隆的子模块执行 `git submodule update`) | macOS 上失败;Linux 上克隆成功但配置条目写不进去 |
| `git branch --set-upstream-to`、`git checkout --track`、`git push -u` | 报告写入被拒但退出码为 0;不会记录上游 |
需要添加或初始化子模块时,在沙盒外运行(终端里,或经用户批准的 danger-full-access 重试);已克隆的子模块在沙盒内仍可更新。
命令输出里出现这些路径时,bash 结果会用 `sandbox.git_metadata_protected` 指明,git
退出码为 0 时也一样。`additional_write_dirs` 无法授权。
受保护文件如果已有另一个硬链接,所有沙盒命令都会以 `sandbox.git_metadata_linked` 被拒,
因为经另一个名字的写入会改到它;需要用户在沙盒外删掉那个链接。
限制:
- Linux 上 bubblewrap 只能挂载已存在的路径,也钉不住符号链接:新建尚不存在的
`commondir`、`config.worktree` 或钩子目录、替换 `gitdir:` 路径上的符号链接,在那里都拦不住。
- 在 Linux 上补这一点要靠宿主一侧:宿主自己的 git 固定其 git 目录与公共目录,而不是重新发现。
- 已有的 worktree 与子模块 gitdir 用精确规则,macOS 上各至多 128 个,Linux 上至多 512 个。
- macOS 上其余的以及之后新建的由模式覆盖。模式跳过 `refs/` 和 `logs/`,名为 `config` 或
`hooks` 的分支、标签仍可写,但新建的名为 `hooks` 的子模块会整个被保护。
- macOS 上 worktree 超过 128 个时 `git worktree add` 会失败;子模块超过 128 个时每条命令
启动约慢 0.1 秒。
- Linux 上超过 512 个 gitdir 时整个 `worktrees/` 或 `modules/` 以只读挂载,命令可以通过伪造
`HEAD` 文件触发这一点。命令也可以伪造一个配置带硬链接的子模块 gitdir,之后所有沙盒命令都会
被拒,直到用户删掉它。
- 沙盒命令新建的仓库从下一条命令起受保护。
- 被弄成 git 不认识的 `.git`(例如损坏的 `HEAD`)会让 git 继续向上查找。
- 工作区里嵌套的、不是 `modules/` 下子模块 gitdir 的仓库不受保护,包括命令自己建出来并
记录成 gitlink 的。
- 除非宿主自己的 git 排除它,宿主可能经由该 gitlink 执行它的配置。
- `core.hooksPath` 指向 `.git` 之外的钩子、`include.path` 引用的文件,都是普通工作区文件。
- Windows 没有 Bash 沙盒,以上都不生效。
**会话私有标准临时目录。**同一逻辑会话内的多条 Bash 命令共享一个私有临时目录,
因此连续调用可以通过 `$TMPDIR` 交换文件(在 Linux bubblewrap 下还可以通过字面
`/tmp`)。用户不需要设置:Reasonix 会自动为 Bash 和客户端托管的 ACP 终端注入
+36 -28
View File
@@ -8,7 +8,26 @@ record, checks its provenance and bytes, and requires one `release` approval
before creating the three tags and publishing. `recover` reuses the same sealed
files; it must not rebuild or sign them.
## Public-site recovery / 官网独立恢复
Rendering the notes credits people: each `refs` number that is a pull request
gains `by @author`; an issue gains `fixed in #M by @author` for each merged pull
request GitHub links as closing it. A closing Contributors list names those
humans once, in first-appearance order. Issues fixed by a direct push and bot
authors are left plain. The lookup needs a GitHub token (`GH_TOKEN`, else
`gh auth token` locally) and `issues: read` plus `pull-requests: read`.
Transient failures (5xx, rate limits, dropped connections) are retried with
backoff; one that persists, or an authorisation failure, fails the render with
a `release_credits.*` code instead of publishing notes without that credit. A
number that names a discussion or nothing stays plain with a warning.
The seal job renders the notes once into `evidence/release-notes.md` inside the
candidate payload and records it as `notes.renderedPath`, bound to
`notes.renderedSha256`. Promotion verifies the payload and publishes those
bytes without calling GitHub, so an author renamed, an issue newly linked to a
fix or an API outage after sealing cannot change or block what ships. A
candidate sealed before this has no `renderedPath` and is rendered offline from
its own source, as before.
## Public checks / 公开渠道检查
Candidate preparation and publication execute offline identity, resolver-output,
archive-integrity, atomic-tag, and publication-ledger contracts before expensive
@@ -20,34 +39,23 @@ a new release, but rejects HTTP errors, browser challenges, and malformed JSON.
另从 runner 以无凭据请求检查公开 Stable 清单;发新版前允许清单仍为旧版,但 HTTP 错误、浏览器挑战及
无效 JSON 必须失败。离线契约测试与在线可访问性检查相互独立。
For an already-published release whose website deployment or public verification
failed, dispatch **Recover release site** (`release-site-recovery.yml`) on
protected `main-v2` with `version=X.Y.Z`. It resolves all existing tags on protected
history, re-reads public package identities, and requires one `release` approval.
After approval it rechecks the public state, requires an existing matching release
event, and uses the same site-sync owner as full publication. No candidate artifact
is needed, so this remains usable after candidate payload expiry. It does not
build, sign, create tags, upload release assets, publish packages, or move pointers.
Missing packages or a missing release event require full publication recovery.
After every publisher succeeds, postflight verifies the tags, the CLI and
Desktop GitHub Releases, npm, the Desktop Stable manifest on `dl.reasonix.io`
and the Homebrew cask, then attaches `release-event.json`. Publication is
complete when those product surfaces are verified. A 90-day ledger records the
source/control SHA, observed surfaces, and the exact failed stage. On recovery
a proven newer Stable pointer is preserved, never a failed HTTP observation.
已发布版本若官网部署或公开验收失败,可在受保护 `main-v2` 上运行 **Recover release site**,输入
`version=X.Y.Z`。它校验三个已有标签属于受保护历史,重新读取公开包身份,并要求一次 `release`
审批。审批后再次核验公开状态及已有的匹配发布事件,复用完整发布的官网同步脚本。不依赖候选产物,
因此候选 payload 过期后仍可使用;不构建、不签名、不建标签、不上传安装包、不发布 npm、不移动指针。
缺少包或发布事件时,应使用完整发布恢复。
发布任务全部成功后,postflight 核验三个标签、CLI 与桌面端 GitHub Release、npm、`dl.reasonix.io`
上的桌面端 Stable 清单和 Homebrew cask,再附上 `release-event.json`;这些产品渠道全部核验通过即发布完成。
90 天台账保留产品/控制 SHA、已验证渠道及失败阶段。恢复时只有确证存在更新的 Stable 指针才予以保留,
HTTP 失败不会被当作“新版已发布”。
The workflow shares the global publication lock. A proven newer Stable pointer
preserves the newer site. Each Pages dispatch has a unique run/attempt correlation
ID, and Pages rechecks ownership immediately before deploying. The final verifier
checks the hydrated download page. A 90-day ledger records the source/control SHA,
Pages run, observed surfaces, and exact failed stage; a failed HTTP observation
never becomes a successful newer-pointer result. Recovery success is not a claim
that unavailable services were verified.
流程共用全局发布锁;只有确证存在更新 Stable 版本才保留新官网。Pages 调度带当前 run/attempt 的
唯一关联 ID,部署前再次检查版本归属,最后验证浏览器渲染后的下载页。90 天台账保留产品/控制 SHA、
Pages run、已验证渠道及失败阶段;HTTP 失败不会再被当作“新版已部署”。外部访问未恢复时不会报告验收完成。
The website is not part of this pipeline. It is built and deployed from the
`website` branch, and its changelog renders from that branch's own release
catalog, so a new version's page is released there, not here.
官网不在此流程内:它由 `website` 分支独立构建和部署,更新日志由该分支自己的发布目录渲染,新版本页面在那里发布。
For qualification without publication, dispatch `Prepare release candidate` on
protected `main-v2` with `version` and `rehearsal=true`. An already reviewed
@@ -56,7 +64,7 @@ version may be used for this isolated run. It uses separate
cannot pass the normal publish resolver or payload verifier. The Desktop child
accepts the existing version tag only in this non-publishing mode. The run must
still complete source CI, signing, and native acceptance. It creates no tags,
GitHub Releases, npm packages, Homebrew updates, R2 pointers, or site changes.
GitHub Releases, npm packages, Homebrew updates, or R2 pointers.
After sealing, run `Verify release candidate rehearsal` on `main-v2` with its
candidate ID. This independent workflow downloads the exact record and payload
@@ -69,7 +77,7 @@ not a publication or a substitute for a later formal release's public checks.
The candidate payload lasts 30 days and its record/evidence 90 days. If the
payload expires before publication, prepare a new candidate. The release
skill's public postflight remains the authority for tags, npm, Desktop
updates, Homebrew, and the hydrated website after an authorized publication.
updates, and Homebrew after an authorized publication.
## Tag publisher identity / 标签发布身份
+13
View File
@@ -6,6 +6,19 @@ Desktop 文件、执行最终安装包的原生验收并封存记录。`Publish
只接受该记录,验证来源与文件字节,经过一次 `release` 批准后创建三个标签并发布。
`recover` 复用相同的封存文件,不重新构建或签名。
渲染发布说明时会为贡献者署名:`refs` 中属于合并请求的编号后面加上
`by @作者`;属于 issue 的编号,对 GitHub 记录为关闭它的每个已合并请求加上
`fixed in #M by @作者`。结尾的「贡献者」列表按首次出现顺序列出这些真人作者,
每人一次。直接推送修复的 issue 和机器人作者保持原样。查询需要 GitHub 令牌(`GH_TOKEN`,
本地没有时用 `gh auth token`),以及 `issues: read` 和 `pull-requests: read` 权限。
暂时性失败(5xx、限流、连接中断)会退避重试;重试后仍失败或鉴权失败时,渲染以
`release_credits.*` 错误码失败,而不是发布缺少署名的说明。指向讨论或不存在的编号保持原样并给出警告。
封存任务只渲染一次,写进候选包的 `evidence/release-notes.md`,记录为
`notes.renderedPath` 并与 `notes.renderedSha256` 绑定。发布时校验候选包后直接发布这份
内容,不再访问 GitHub,因此封存之后作者改名、issue 新关联了修复或接口故障都不会改变或
阻塞发布内容。本改动之前封存的候选没有 `renderedPath`,仍按原方式从自身源码离线渲染。
需要验收但不发布时,在受保护的 `main-v2` 上调度 `Prepare release candidate`,
填写 `version` 和 `rehearsal=true`。可使用已有审核完成的版本进行隔离演练。
它使用独立的 `release-candidate-rehearsal-*` 产物,记录
+4 -1
View File
@@ -1134,7 +1134,10 @@ cursor behavior.
`[serve]` controls the HTTP browser frontend used by `reasonix serve`. The
default `auth_mode = "none"` is intended for the loopback default
`127.0.0.1:8787`; deployments reachable from another machine must use `token` or
`password`. Password mode requires either a startup `--password` or a stored
`password`. In `none` mode every state-changing request still needs the launch
token, which Serve writes to a 0600 file under `<Reasonix home>/remote/` rather
than to the terminal, and otherwise refuses with `launch_token_required`.
`[serve]` is read from the user config only. Password mode requires either a startup `--password` or a stored
bcrypt `password_hash`. `behind_proxy` must stay false unless the server is
behind a trusted proxy that owns the `X-Forwarded-For` and `X-Forwarded-Proto`
headers.
+1 -1
View File
@@ -453,7 +453,7 @@ auth_mode = "none"
权限预设直接选择强制沙盒边界。交互会话可以用「扩展写入范围」授权(仅本次 / 本会话 / 拒绝)按需扩大可写根;文件工具会申请目标父目录,Bash 必须声明 `additional_write_dirs` 和 `justification`。无头 `reasonix run` 缺少目录时失败关闭。file writer 默认限制在 workspace root、会话私有临时目录和显式授权根;`forbid_read` 可阻止读取敏感路径。macOS 使用 Seatbelt,Linux 使用 bubblewrap;受限 backend 不可用时拒绝执行,不无约束回退。Windows 没有 OS 级 Shell 沙箱:受限预设仍约束文件工具,Shell 命令以当前系统账户运行。
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
`[serve]` 控制 `reasonix serve` 的 browser frontend。默认 `auth_mode = "none"` 仅适合 loopback;暴露到其他机器时必须使用 token 或 password。`none` 模式下所有会改变状态的请求仍需启动令牌(serve 把它写进 `<Reasonix home>/remote/` 下 0600 文件而非终端),否则以 `launch_token_required` 拒绝;`[serve]` 只从用户配置读取。只有位于可信 reverse proxy 后方时才能启用 `behind_proxy`。
项目根目录的 `.mcp.json` 可使用 Claude Code 的 `mcpServers` schema;与 `reasonix.toml` 同名时,以后者为准。
+2 -1
View File
@@ -51,7 +51,8 @@ This is a real isolated subagent run, not prompt text inserted into the parent
agent. The parent conversation retains the task and the child's final answer,
not the child's full working context. Review and security-review children also
receive a compact parent facts pack (confirmed decisions, evidence summary, file
anchors) and default to 8 steps plus a 2048 output-token cap.
anchors) and a 2048 output-token cap per response; their step budget is the
same as any other sub-agent's (an explicit `max_steps` wins).
The parent model can also select a profile at call time without listing profile
names in the tool schema (prompt-cache stability):
+2 -1
View File
@@ -45,7 +45,8 @@ printf '%s\n' '检查任务,只报告可执行的问题。' | \
这会真正启动隔离子智能体,并非把提示词文本注入父智能体。父会话只保留任务和子智能体的
最终答案,不保留子智能体的完整工作上下文。review / security-review 子代理还会拿到一份
精简的父会话事实包(已确认决策、证据摘要、文件锚点),默认 8 步和 2048 输出 token。
精简的父会话事实包(已确认决策、证据摘要、文件锚点),每次回复上限 2048 输出 token;
步数预算与其他子代理相同(显式 `max_steps` 优先)。
父模型也可以在调用时选择 Profile,且不会把 Profile 名称列表写进工具 schema(保持
prompt-cache 稳定):
+6 -6
View File
@@ -6,7 +6,7 @@ toolchain go1.26.6
require (
charm.land/bubbles/v2 v2.2.1
charm.land/bubbletea/v2 v2.0.9
charm.land/bubbletea/v2 v2.0.10
charm.land/lipgloss/v2 v2.0.6
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3
github.com/BurntSushi/toml v1.6.0
@@ -14,7 +14,7 @@ require (
github.com/alecthomas/chroma/v2 v2.27.0
github.com/atotto/clipboard v0.1.4
github.com/aymanbagabas/go-udiff v0.4.1
github.com/bmatcuk/doublestar/v4 v4.10.0
github.com/bmatcuk/doublestar/v4 v4.10.2
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/x/ansi v0.11.8
github.com/creack/pty v1.1.24
@@ -23,10 +23,10 @@ require (
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/kevinburke/ssh_config v1.6.0
github.com/klauspost/compress v1.18.4
github.com/klauspost/compress v1.18.7
github.com/larksuite/oapi-sdk-go/v3 v3.11.0
github.com/mattn/go-runewidth v0.0.29
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2
github.com/mattn/go-runewidth v0.0.30
github.com/modelcontextprotocol/go-sdk v1.8.0
github.com/pkg/sftp v1.13.11
github.com/rivo/uniseg v0.4.7
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
@@ -52,7 +52,7 @@ require (
golang.org/x/text v0.41.0
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.58.0
mvdan.cc/sh/v3 v3.14.0
mvdan.cc/sh/v3 v3.14.1
)
require (
+12 -12
View File
@@ -1,7 +1,7 @@
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
charm.land/bubbletea/v2 v2.0.10 h1:oolvo20VBpI0PfqE7iFjkZ1bx0WpmXGfnKz5Yldjq5o=
charm.land/bubbletea/v2 v2.0.10/go.mod h1:QOatcnhOjYIfxzUSTz6raF7Ex4R/rIuHa3SnBdCCpMc=
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
@@ -22,8 +22,8 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=
github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/bmatcuk/doublestar/v4 v4.10.2 h1:eF7W7HWKg3z9NrWV9pTLnNeoXaqq3Tq9DNKXVMfoCnw=
github.com/bmatcuk/doublestar/v4 v4.10.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
@@ -88,8 +88,8 @@ github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRg
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -104,10 +104,10 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-pointer v0.0.1 h1:n+XhsuGeVO6MEAp7xyEukFINEa+Quek5psIR/ylA6o0=
github.com/mattn/go-pointer v0.0.1/go.mod h1:2zXcozF6qYGgmsG+SeTZz3oAbFLdD3OWqnUbNvJZAlc=
github.com/mattn/go-runewidth v0.0.29 h1:3oGF3R/S2N9DQ3ptftzVIvg2eicmojCzlwBEmqEPDfQ=
github.com/mattn/go-runewidth v0.0.29/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2 h1:Sxti6bD15KmHiTqcJ54nt9G6885m8DVwAxZX6Iu4Kjc=
github.com/modelcontextprotocol/go-sdk v1.8.0-pre.2/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/mattn/go-runewidth v0.0.30 h1:+KUuiDA4fF0R1p5FeueHefjDm+GIM+kWfFnDjybOPgk=
github.com/mattn/go-runewidth v0.0.30/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
github.com/modelcontextprotocol/go-sdk v1.8.0 h1:KIvahhYqwtbeniWVPs3TcXEA7b8jEtwfBpOTAI+Urx4=
github.com/modelcontextprotocol/go-sdk v1.8.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -266,5 +266,5 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
mvdan.cc/sh/v3 v3.14.0 h1:wsPjcfrSXLJomdxwhVZqZ5j5Cue68l549Aj/DCqzbpc=
mvdan.cc/sh/v3 v3.14.0/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
mvdan.cc/sh/v3 v3.14.1 h1:bXkhQWNHCs0KZEChF8hYS6FC+T2N9mUZLbQv9blditI=
mvdan.cc/sh/v3 v3.14.1/go.mod h1:syYCoFET8w9tvevxiXUtY8/ICrU+l26jHmhJDra3Vwo=
+6 -6
View File
@@ -323,9 +323,9 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
sess := &acpSession{id: "sess-1", ctrl: newExtActionController(t, client)}
svc := &service{}
got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
if got != "rerun scheduled" {
t.Fatalf("resolveSlashPrompt = %q, want the action result", got)
got, invoked := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:act1 k=v extra")
if got != "rerun scheduled" || invoked != "" {
t.Fatalf("resolveSlashPrompt = (%q, %q), want the action result without a skill", got, invoked)
}
if client.got == nil || client.got.ActionID != "act1" ||
client.got.Args["k"] != "v" || client.got.Args["arg1"] != "extra" {
@@ -333,10 +333,10 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
}
// Undeclared actions and non-action lines pass through untouched.
if got := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/alpha:other"); got != "/alpha:other" {
t.Fatalf("undeclared action rewrote to %q", got)
}
if got := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/plain"); got != "/plain" {
t.Fatalf("plain slash rewrote to %q", got)
}
@@ -345,7 +345,7 @@ func TestResolveSlashPromptFallsThroughToExtensionAction(t *testing.T) {
failing := &acpSession{id: "sess-1", ctrl: newExtActionController(t, &extActionClient{
result: protocol.UIActionResult{Accepted: false, Message: "nope"},
})}
if got := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
if got, _ := svc.resolveSlashPrompt(context.Background(), failing, "/alpha:act1"); got != "/alpha:act1" {
t.Fatalf("failed action rewrote to %q", got)
}
}
+15 -11
View File
@@ -263,6 +263,8 @@ func (s *service) bindClientIO(p *SessionParams, sessionID string) {
type acpController interface {
control.Lifecycle
control.TurnControl
RunTurnWithRaw(ctx context.Context, input, raw, invokedSkill string) error
RunSkillWithName(input string) (sent, name string, found bool)
RunFinalReadinessRecoveryWithAdmission(ctx context.Context, input string, onAdmitted func()) error
TrySteer(text string) bool
control.Approvals
@@ -1158,6 +1160,8 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: unknown session " + p.SessionID}
}
text := FlattenPrompt(p.Prompt)
rawText := text
invokedSkill := ""
if text == "" && p.Action != control.ProtocolRecoveryAction {
return nil, &RPCError{Code: ErrInvalidParams, Message: "session/prompt: empty prompt"}
}
@@ -1178,7 +1182,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
recovery = true
text = prompt
} else {
text = s.resolveSlashPrompt(ctx, sess, text)
text, invokedSkill = s.resolveSlashPrompt(ctx, sess, text)
}
}
@@ -1217,7 +1221,7 @@ func (s *service) sessionPrompt(ctx context.Context, raw json.RawMessage) (any,
runErr = sess.ctrl.RunFinalReadinessRecoveryWithAdmission(runCtx, text, beginTurn)
} else {
beginTurn()
runErr = sess.ctrl.RunTurn(runCtx, text)
runErr = sess.ctrl.RunTurnWithRaw(runCtx, text, rawText, invokedSkill)
}
if errors.Is(runErr, agent.ErrProtocolRecoveryUnavailable) && !statusStarted {
return nil, &RPCError{Code: ErrInvalidRequest, Message: "session/prompt: protocol recovery is unavailable or stale"}
@@ -2588,28 +2592,28 @@ func availableCommandsFor(ctrl acpController) []AvailableCommand {
return out
}
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) string {
func (s *service) resolveSlashPrompt(ctx context.Context, sess *acpSession, text string) (string, string) {
line := strings.TrimSpace(text)
if sess == nil || !strings.HasPrefix(line, "/") {
return text
return text, ""
}
ctrl := sess.currentCtrl()
if ctrl == nil {
return text
return text, ""
}
if sent, ok := ctrl.CustomCommand(line); ok {
return sent
return sent, ""
}
if sent, ok := ctrl.RunSkill(line); ok {
return sent
if sent, name, ok := ctrl.RunSkillWithName(line); ok {
return sent, name
}
if sent, ok, err := ctrl.MCPPrompt(ctx, line); err == nil && ok {
return sent
return sent, ""
}
if sent, ok := invokeExtensionAction(ctx, ctrl, line); ok {
return sent
return sent, ""
}
return text
return text, ""
}
// invokeExtensionAction resolves a "/<plugin>:<action> args…" line against the
+1 -1
View File
@@ -424,7 +424,7 @@ func TestACPCtrlReadPathsDoNotRaceWithRebuild(t *testing.T) {
}
_ = svc.sessionDir()
svc.sendAvailableCommands(sess)
if got := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
if got, _ := svc.resolveSlashPrompt(context.Background(), sess, "/no-such-command args"); got != "/no-such-command args" {
t.Fatalf("resolveSlashPrompt rewrote unknown command to %q", got)
}
}
+85
View File
@@ -0,0 +1,85 @@
package acp
import (
"context"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/agent/testutil"
"reasonix/internal/control"
"reasonix/internal/provider"
"reasonix/internal/skill"
"reasonix/internal/tool"
)
type skillRawFactory struct {
provider *testutil.MockProvider
executor chan *agent.Agent
dir string
}
func (f *skillRawFactory) SessionDir() string { return f.dir }
func (f *skillRawFactory) NewSession(_ context.Context, p SessionParams) (*control.Controller, error) {
executor := agent.New(f.provider, tool.NewRegistry(), agent.NewSession("test agent"),
agent.Options{MaxSteps: 2}, p.Sink)
ctrl := control.New(control.Options{
Runner: executor, Executor: executor, Sink: p.Sink,
SessionDir: f.dir,
Skills: []skill.Skill{
{Name: "probe", Description: "probe skill", Body: "PROBE BODY\nthen /other", Triggers: []string{"probe", "tidy"}, AutoUse: "require"},
{Name: "other", Description: "other skill", Body: "OTHER BODY", Triggers: []string{"other"}, AutoUse: "require"},
},
})
f.executor <- executor
return ctrl, nil
}
func TestACPSkillPromptKeepsTypedTextAsRawInput(t *testing.T) {
factory := &skillRawFactory{
provider: testutil.NewMock("test", testutil.Turn{Text: "done"}),
executor: make(chan *agent.Agent, 1),
dir: t.TempDir(),
}
client, stop := startServer(t, factory)
defer stop()
sid := openSession(t, client)
executor := <-factory.executor
const typed = "/probe tidy the notes"
prompt := client.callAsync("session/prompt", SessionPromptParams{
SessionID: sid,
Prompt: []ContentBlock{{Type: "text", Text: typed}},
})
_, resp := drainPrompt(t, client, prompt)
if resp.Error != nil {
t.Fatalf("session/prompt: %v", resp.Error)
}
req := factory.provider.LastRequest()
if req == nil {
t.Fatal("no provider request")
}
var delivered string
for _, msg := range req.Messages {
if msg.Role == provider.RoleUser {
delivered = msg.Content
}
}
if !strings.Contains(delivered, "PROBE BODY") || !strings.Contains(delivered, "Arguments: tidy the notes") {
t.Fatalf("provider did not receive the rendered skill invocation: %q", delivered)
}
if strings.Contains(delivered, "<capability-route") {
t.Fatalf("already-pinned skill was routed again: %q", delivered)
}
for _, msg := range executor.Session().Snapshot() {
if msg.Role == provider.RoleUser && strings.Contains(msg.Content, "PROBE BODY") {
if msg.RawContent != typed {
t.Fatalf("raw user input = %q, want %q", msg.RawContent, typed)
}
return
}
}
t.Fatal("no user turn in history")
}
+31 -2
View File
@@ -4,6 +4,9 @@ import (
"errors"
"fmt"
"strings"
"reasonix/internal/config"
"reasonix/internal/plugin"
)
func (r *MCPCapabilityRuntime) serverRegistered(server string) bool {
@@ -24,12 +27,32 @@ func mcpServerDisabledMessage(server string) string {
return fmt.Sprintf("MCP server %q is disabled in this session", server)
}
// mcpServerOffMessage carries the host's reason a server is off. One the
// project declared waits for the user, and only the user can change that.
func mcpServerOffMessage(server string, decision config.MCPDecision) string {
if !decision.AwaitsUser() {
return mcpServerDisabledMessage(server)
}
return fmt.Sprintf("MCP server %q is off [%s]: this project declares it, and it starts only after the user reviews and enables it (reasonix mcp enable %s, /mcp connect, or the MCP panel). Tell the user if it is needed.", server, decision.Code(), server)
}
// serverDecision resolves why entry is off; an enabled server is simply on.
func serverDecision(entry config.PluginEntry, spec plugin.Spec, enabled bool) config.MCPDecision {
if enabled {
return config.MCPDecisionOn
}
if d := config.MCPServerDecision(entry, spec.WorkspaceRoot); d.AwaitsUser() {
return d
}
return config.MCPDecisionOff
}
func mcpServerUnregisteredError(server string) error {
return errors.New(mcpServerUnregisteredMessage(server))
}
func mcpServerDisabledError(server string) error {
return errors.New(mcpServerDisabledMessage(server))
func mcpServerOffError(server string, decision config.MCPDecision) error {
return errors.New(mcpServerOffMessage(server, decision))
}
func (t *UseCapabilityTool) serverRegistered(server string) bool {
@@ -46,5 +69,11 @@ func (t *UseCapabilityTool) serverUnavailableReason(server string) string {
if !t.serverRegistered(server) {
return mcpServerUnregisteredMessage(server)
}
if t.runtime != nil {
t.runtime.mu.RLock()
configured := t.runtime.servers[strings.TrimSpace(server)]
t.runtime.mu.RUnlock()
return mcpServerOffMessage(server, configured.decision)
}
return mcpServerDisabledMessage(server)
}
+64
View File
@@ -0,0 +1,64 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"reasonix/internal/agent/testutil"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// A review child under an uncapped parent takes as many rounds as the review
// needs; a multi-file review that reads more than a handful of files must not
// stop at a budget the user has no setting for.
func TestReviewSubagentUnderUncappedParentFinishesPastEightRounds(t *testing.T) {
const rounds = 12
turns := make([]testutil.Turn, 0, rounds+1)
for i := range rounds {
turns = append(turns, testutil.Turn{ToolCalls: []provider.ToolCall{{
ID: fmt.Sprintf("read-%d", i), Name: "echo", Arguments: fmt.Sprintf(`{"text":"file %d"}`, i),
}}})
}
turns = append(turns, testutil.Turn{Text: "verdict: approve"})
parent := tool.NewRegistry()
parent.Add(echoTool{})
task := NewTaskTool(testutil.NewMock("sub", turns...), nil, parent, 0, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(mustSubagentStore(t), t.TempDir(), "base", "high").
WithProfileLookup(func(name string) (ProfileDefinition, bool) {
return ProfileDefinition{Name: name, Body: "You review changes."}, name == "review"
})
out, err := task.Execute(withCallContext(context.Background(), "c", event.Discard, nil, false),
json.RawMessage(`{"prompt":"review the change","profile":"review"}`))
if err != nil {
t.Fatalf("review task: %v", err)
}
if strings.Contains(out, "paused after") || !strings.Contains(out, "verdict: approve") {
t.Fatalf("review child must finish its review, got:\n%s", out)
}
}
func TestReviewBudgetKeepsInheritedStepsAndBoundsOutput(t *testing.T) {
for _, tc := range []struct {
parent, requested, want int
}{
{parent: 0, requested: 0, want: 0},
{parent: 40, requested: 0, want: 20},
{parent: 0, requested: 24, want: 24},
} {
task := &TaskTool{maxSteps: tc.parent}
spec := ProfileExecSpec{Worker: WorkerSpec{Profile: "review"}, Sched: SchedulerPolicy{MaxSteps: tc.requested}}
ctx, steps := task.childMaxStepsForSpec(context.Background(), &spec)
if steps != tc.want {
t.Fatalf("parent=%d requested=%d: steps = %d, want %d", tc.parent, tc.requested, steps, tc.want)
}
if childOutputBudgetFrom(ctx) != defaultReviewOutputTokens {
t.Fatalf("review output budget = %d", childOutputBudgetFrom(ctx))
}
}
}
+4 -8
View File
@@ -9,7 +9,6 @@ import (
"reasonix/internal/evidence"
)
const defaultReviewMaxSteps = 8
const defaultReviewOutputTokens = 2048
func composeChildTaskPrompt(spec ProfileExecSpec) string {
@@ -55,9 +54,6 @@ func applyReviewBudget(spec *ProfileExecSpec) {
}
switch strings.TrimSpace(spec.Worker.Profile) {
case "review", "security-review", "security_review", "team-architect":
if spec.Sched.MaxSteps <= 0 {
spec.Sched.MaxSteps = defaultReviewMaxSteps
}
if spec.Sched.MaxOutputTokens <= 0 {
spec.Sched.MaxOutputTokens = defaultReviewOutputTokens
}
@@ -70,17 +66,17 @@ func applyReviewBudget(spec *ProfileExecSpec) {
// PrepareReviewSubagentContext applies the same bounded review contract used
// by task/profile delegation to built-in skill runners. The returned boolean
// is false for non-review profiles so their existing budgets remain unchanged.
func PrepareReviewSubagentContext(ctx context.Context, profile, objective string) (prompt string, maxSteps, maxOutputTokens int, ok bool) {
func PrepareReviewSubagentContext(ctx context.Context, profile, objective string) (prompt string, maxOutputTokens int, ok bool) {
spec := ProfileExecSpec{
Task: TaskSpec{Objective: objective},
Worker: WorkerSpec{Profile: profile},
}
applyReviewBudget(&spec)
if spec.Sched.MaxSteps == 0 && spec.Sched.MaxOutputTokens == 0 {
return objective, 0, 0, false
if spec.Sched.MaxOutputTokens == 0 {
return objective, 0, false
}
fillChildFacts(ctx, &spec)
return composeChildTaskPrompt(spec), spec.Sched.MaxSteps, spec.Sched.MaxOutputTokens, true
return composeChildTaskPrompt(spec), spec.Sched.MaxOutputTokens, true
}
type childOutputBudgetKey struct{}
+6 -6
View File
@@ -29,7 +29,7 @@ func TestComposeChildTaskPromptUsesFactsPack(t *testing.T) {
func TestApplyReviewBudgetDefaults(t *testing.T) {
spec := ProfileExecSpec{Worker: WorkerSpec{Profile: "review"}}
applyReviewBudget(&spec)
if spec.Sched.MaxSteps != defaultReviewMaxSteps || spec.Sched.MaxOutputTokens != defaultReviewOutputTokens {
if spec.Sched.MaxSteps != 0 || spec.Sched.MaxOutputTokens != defaultReviewOutputTokens {
t.Fatalf("budget = %+v", spec.Sched)
}
}
@@ -64,7 +64,7 @@ func TestChildMaxStepsForSpecStampsReviewOutputBudget(t *testing.T) {
task := &TaskTool{}
spec := ProfileExecSpec{Worker: WorkerSpec{Profile: "review"}}
ctx, steps := task.childMaxStepsForSpec(context.Background(), &spec)
if steps != defaultReviewMaxSteps {
if steps != 0 {
t.Fatalf("steps = %d", steps)
}
if childOutputBudgetFrom(ctx) != defaultReviewOutputTokens {
@@ -83,16 +83,16 @@ func TestPrepareReviewSubagentContextAddsBoundedVerifiedFacts(t *testing.T) {
ToolName: "go_test", Success: true, Read: true, Paths: []string{"z.go", "a.go"},
OutputBytes: 42, OutputDigest: "0123456789abcdef", ExitCode: &exit, Verification: evidence.VerificationPassed,
})
prompt, steps, tokens, ok := PrepareReviewSubagentContext(evidence.WithLedger(context.Background(), ledger), "review", "review change")
if !ok || steps != defaultReviewMaxSteps || tokens != defaultReviewOutputTokens {
t.Fatalf("review budget = ok:%v steps:%d tokens:%d", ok, steps, tokens)
prompt, tokens, ok := PrepareReviewSubagentContext(evidence.WithLedger(context.Background(), ledger), "review", "review change")
if !ok || tokens != defaultReviewOutputTokens {
t.Fatalf("review budget = ok:%v tokens:%d", ok, tokens)
}
for _, want := range []string{"tool=go_test", "output_bytes=42", "output_digest=0123456789ab", "verification=passed", "a.go", "verdict"} {
if !strings.Contains(prompt, want) {
t.Fatalf("review prompt missing %q:\n%s", want, prompt)
}
}
if _, _, _, ok := PrepareReviewSubagentContext(context.Background(), "explore", "look"); ok {
if _, _, ok := PrepareReviewSubagentContext(context.Background(), "explore", "look"); ok {
t.Fatal("non-review profile must retain its existing runner budget")
}
}
Loaded 100 of 498 files, more files were not shown because too many files have changed in this diff. Show more