refactor(desktop): Studio is the only shell the module builds
The Wails-bound app and its React frontend are gone. Studio reaches the kernel through internal/serve's HTTP routes instead of bound Go methods, so the shell that replaces ~320k lines is next/ plus the SPA it serves. Three things the deleted tree owned move rather than disappear: the WebView2 fork's patch guard, the macOS dock-icon safe-area assertion, and the Windows ICO the resource stamper needs. The fork itself stays — its mixed-DPI and proxy-isolation patches are Studio's too. CI keeps one desktop Go gate per platform, now built against WebKitGTK 4.1 the way studio-build.sh links; the frontend legs, the Wails CLI pin, and the 4.0 leg went with the app. The desktop release line (release-desktop.yml and the desktop channel in release-stable.yml) is untouched here: it still serves installs that are already out there, and unwiring it is release-pipeline surgery of its own.
No files matched your search
@@ -316,22 +316,6 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
|
||||
|
||||
- name: gofmt
|
||||
run: |
|
||||
unformatted=$(gofmt -l .)
|
||||
@@ -350,34 +334,15 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# WebKitGTK 4.0 toolchain (pinned to ubuntu-22.04; no webkit2_41 tag).
|
||||
# WebKitGTK 4.1 is what studio-build.sh links against: 4.0
|
||||
# (libwebkit2gtk-4.0.so.37) is gone on Ubuntu 24.04+ and Fedora 40+.
|
||||
- name: Install Linux build deps
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.0-dev xvfb
|
||||
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
PLAYWRIGHT_BROWSERS_PATH=.pw-browsers pnpm --dir frontend exec playwright install --with-deps chromium
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: Test desktop frontend
|
||||
run: |
|
||||
pnpm --dir frontend test:terminal
|
||||
pnpm --dir frontend test:task-monitor
|
||||
pnpm --dir frontend test:workspace
|
||||
pnpm --dir frontend test:motion
|
||||
pnpm --dir frontend test:motion-browser
|
||||
pnpm --dir frontend test:transcript
|
||||
pnpm --dir frontend test:transcript-browser
|
||||
|
||||
- name: Test usage statistics frontend
|
||||
run: pnpm --dir frontend test:usage-stats
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev
|
||||
|
||||
- name: vet
|
||||
run: go vet ./...
|
||||
run: go vet -tags webkit2_41 ./...
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
@@ -387,51 +352,19 @@ jobs:
|
||||
args: --timeout=5m
|
||||
|
||||
- name: build
|
||||
run: go build ./...
|
||||
run: go build -tags webkit2_41 ./...
|
||||
|
||||
- name: test
|
||||
run: go test ./...
|
||||
|
||||
- name: WebKitGTK 4.0 native recovery smoke
|
||||
run: xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 go test -tags reasonix_webkit_smoke . -run '^TestWebKitNativeRecoverySmoke$'
|
||||
|
||||
# The extension work added new shared-state paths to the desktop
|
||||
# runtime (tab/rebuild fences, extension UI). Race-sweep the module so
|
||||
# regressions are CI-blocked, not just author-verified locally.
|
||||
- name: test -race
|
||||
run: go test -race ./...
|
||||
|
||||
# Ubuntu 24.04 ships WebKitGTK 4.1 only. Keep this capability build separate
|
||||
# from the 22.04/4.0 leg so neither runtime becomes a distro whitelist.
|
||||
desktop-linux-webkit41:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: desktop/go.mod
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- name: Install WebKitGTK 4.1 build dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gcc libgtk-3-dev libwebkit2gtk-4.1-dev xvfb
|
||||
|
||||
- name: Build and test WebKitGTK 4.1 observer
|
||||
run: go test -tags webkit2_41 ./...
|
||||
|
||||
- name: WebKitGTK 4.1 native recovery smoke
|
||||
run: xvfb-run -a env WEBKIT_DISABLE_COMPOSITING_MODE=1 go test -tags 'webkit2_41 reasonix_webkit_smoke' . -run '^TestWebKitNativeRecoverySmoke$'
|
||||
# The shell's tab/rebuild fences and extension UI are shared state.
|
||||
# Race-sweep the module so regressions are CI-blocked, not just
|
||||
# author-verified locally.
|
||||
- name: test -race
|
||||
run: go test -race -tags webkit2_41 ./...
|
||||
|
||||
# desktop/ is a separate module, so the root macOS matrix above does not
|
||||
# compile or exercise the native PTY implementation.
|
||||
# compile it against a real macOS toolchain.
|
||||
desktop-macos:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
@@ -448,11 +381,11 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- name: Test integrated terminal and PTY lifecycle
|
||||
run: go test -race -run 'Test(ResolveTerminal|Terminal|EmptyTerminal|UnixTerminalProcess)' .
|
||||
- name: test
|
||||
run: go test -race ./...
|
||||
|
||||
# desktop/*_darwin.go is the one build-tag set the lint job cannot reach:
|
||||
# the main-thread watchdog is cgo, so it only type-checks with a real
|
||||
# desktop's *_darwin.go is the one build-tag set the lint job cannot
|
||||
# reach: the dock-icon glue is cgo, so it only type-checks with a real
|
||||
# macOS toolchain.
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v9
|
||||
@@ -461,10 +394,8 @@ jobs:
|
||||
working-directory: desktop
|
||||
args: --timeout=5m
|
||||
|
||||
# Desktop project-root matching is case-insensitive only on Windows
|
||||
# (sameDesktopPath folds case when os.PathSeparator is '\'), so the
|
||||
# regression tests for that contract are named *OnWindows and can never
|
||||
# run on the ubuntu leg above.
|
||||
# The update helper's versioned-install and uninstall paths are Windows-only
|
||||
# (//go:build windows), so the ubuntu leg above never compiles them.
|
||||
desktop-windows:
|
||||
needs: changes
|
||||
if: always() && (github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false')
|
||||
@@ -482,72 +413,14 @@ jobs:
|
||||
cache: true
|
||||
cache-dependency-path: desktop/go.sum
|
||||
|
||||
- uses: pnpm/[email protected]
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: Install Wails CLI
|
||||
run: |
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(cat "$GITHUB_WORKSPACE/.wails-version")"
|
||||
|
||||
# go:embed of frontend/dist needs a built frontend before the package
|
||||
# compiles, same as the ubuntu desktop leg.
|
||||
- name: Build frontend
|
||||
run: |
|
||||
wails generate module
|
||||
pnpm --dir frontend install --frozen-lockfile
|
||||
pnpm --dir frontend build
|
||||
|
||||
- name: Test native motion contracts
|
||||
run: pnpm --dir frontend test:motion
|
||||
|
||||
- name: Test WebView2 native smoke state machine
|
||||
shell: pwsh
|
||||
run: ../scripts/test-webview2-native-smoke.ps1 -SelfTest
|
||||
|
||||
# Build the real Wails executable on every desktop PR and exercise the
|
||||
# exact production startup path in Microsoft's installed WebView2 runtime.
|
||||
# The frontend and bindings were already built above, so this skips only
|
||||
# duplicate build work, not the native shell.
|
||||
- name: Build Wails executable for native startup smoke
|
||||
timeout-minutes: 10
|
||||
run: wails build -clean -s -skipbindings -nopackage -platform windows/amd64 -webview2 embed
|
||||
|
||||
- name: Smoke-test Wails/WebView2 native startup
|
||||
shell: pwsh
|
||||
timeout-minutes: 2
|
||||
run: |
|
||||
../scripts/test-webview2-native-smoke.ps1 `
|
||||
-ExecutablePath "build/bin/reasonix-desktop.exe" `
|
||||
-TimeoutSeconds 60 `
|
||||
-HealthySeconds 5
|
||||
|
||||
- name: test (Windows desktop and update helper)
|
||||
# The vendored WebView2 fork carries the mixed-DPI and proxy-isolation
|
||||
# patches, and its own tests only compile on Windows.
|
||||
- name: test (Windows shell, update helper, and the WebView2 fork)
|
||||
timeout-minutes: 15
|
||||
run: |
|
||||
go test ./...
|
||||
(cd third_party/go-webview2 && go test ./pkg/edge)
|
||||
|
||||
# Installer packaging is packaging validation, not a code gate: run it
|
||||
# on pushes to main-v2 (the release pipeline builds installers again
|
||||
# anyway), but let pull requests stop after the test step.
|
||||
- name: Install NSIS
|
||||
if: github.event_name != 'pull_request'
|
||||
run: pwsh -NoProfile -File ../scripts/install-nsis.ps1
|
||||
|
||||
- name: Build Windows installer and portable archive
|
||||
if: github.event_name != 'pull_request'
|
||||
timeout-minutes: 20
|
||||
run: ../scripts/desktop-build.sh windows/amd64 v0.0.0-ci canary
|
||||
|
||||
# repolint scans desktop/ and sdk/ too, so this job also runs for diffs the
|
||||
# `code` filter would otherwise skip.
|
||||
lint:
|
||||
@@ -562,33 +435,9 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
|
||||
uses: pnpm/[email protected]
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend/pnpm-lock.yaml
|
||||
|
||||
- name: required native motion contracts
|
||||
if: github.event_name != 'pull_request' || needs.changes.outputs.desktop != 'false'
|
||||
run: |
|
||||
pnpm --dir desktop/frontend install --frozen-lockfile
|
||||
pnpm --dir desktop/frontend test:motion
|
||||
|
||||
- name: repo standards
|
||||
run: go run ./tools/repolint
|
||||
|
||||
- name: wails version pin
|
||||
run: |
|
||||
bash scripts/check-wails-pin.sh
|
||||
bash scripts/check-wails-pin.test.sh
|
||||
|
||||
# `make lint-install` reads the same file, so a local run and this job
|
||||
# cannot drift onto different linter versions.
|
||||
- id: golangci
|
||||
@@ -624,7 +473,7 @@ jobs:
|
||||
.github/workflows/release-stable-trigger.yml \
|
||||
.github/workflows/release.yml \
|
||||
.github/workflows/release-npm.yml \
|
||||
.github/workflows/release-desktop.yml \
|
||||
.github/workflows/release-studio.yml \
|
||||
.github/workflows/release-verify-issues.yml \
|
||||
.github/workflows/docs-impact.yml \
|
||||
.github/workflows/ci.yml
|
||||
|
||||
@@ -8,9 +8,8 @@ LDFLAGS := -s -w \
|
||||
GOEXE := $(shell go env GOEXE)
|
||||
# One pin for the Makefile and the CI lint job; see .github/workflows/ci.yml.
|
||||
GOLANGCI_VERSION := $(shell cat .golangci-version)
|
||||
WAILS_VERSION := $(shell tr -d '[:space:]' < .wails-version)
|
||||
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update check wails-install test desktop-test desktop-test-short desktop-test-times sdk-test sdk-test-race hooks cross clean studio
|
||||
.PHONY: build vet fmt lint lint-go lint-install lint-cross lint-update check test studio-test sdk-test sdk-test-race hooks cross clean studio
|
||||
|
||||
build:
|
||||
CGO_ENABLED=0 go build -ldflags "$(LDFLAGS)" -o bin/reasonix$(GOEXE) ./cmd/reasonix
|
||||
@@ -35,8 +34,6 @@ fmt:
|
||||
# particular never surface in `go vet`.
|
||||
lint: lint-go
|
||||
go run ./tools/repolint
|
||||
bash scripts/check-wails-pin.sh
|
||||
bash scripts/check-wails-pin.test.sh
|
||||
|
||||
lint-go:
|
||||
@command -v golangci-lint >/dev/null || { echo "golangci-lint not installed; run: make lint-install"; exit 1; }
|
||||
@@ -60,10 +57,6 @@ check:
|
||||
lint-update:
|
||||
go run ./tools/repolint -update
|
||||
|
||||
wails-install:
|
||||
bash scripts/check-wails-pin.sh
|
||||
go install "github.com/wailsapp/wails/v2/cmd/wails@$(WAILS_VERSION)"
|
||||
|
||||
# Linting one GOOS leaves every //go:build windows and darwin file unchecked.
|
||||
lint-cross:
|
||||
@for t in "linux ." "darwin ." "windows ." "linux desktop" "windows desktop"; do \
|
||||
@@ -75,14 +68,9 @@ lint-cross:
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
desktop-test:
|
||||
cd desktop && go test .
|
||||
|
||||
desktop-test-short:
|
||||
cd desktop && go test -short .
|
||||
|
||||
desktop-test-times:
|
||||
cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
# The shell is a nested module, so the root `test` target never reaches it.
|
||||
studio-test:
|
||||
cd desktop && go test ./...
|
||||
|
||||
sdk-test:
|
||||
cd sdk/go && go test ./...
|
||||
|
||||
@@ -1,308 +1,98 @@
|
||||
# Reasonix Desktop (Wails shell)
|
||||
# Reasonix Studio (Wails shell)
|
||||
|
||||
A native desktop window around the Reasonix Go kernel. The same
|
||||
transport-agnostic `control.Controller` that backs the chat TUI and the HTTP/SSE
|
||||
server is bound **directly** to a React webview — Go methods in, typed events
|
||||
out, no HTTP hop.
|
||||
A native desktop window around the Reasonix Go kernel. The window hosts a React
|
||||
SPA; every request the SPA makes is routed in-process to the kernel's own HTTP
|
||||
server (`internal/serve`) instead of to a set of bound Go methods, so the
|
||||
desktop, `reasonix serve`, and the remote UI all answer the same routes.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ webview (React + TS, Vite) │
|
||||
│ bridge.ts ──calls──▶ window.go.main.App.{Submit,Cancel,…} │
|
||||
│ bridge.ts ◀─events── window.runtime.EventsOn("agent:event")│
|
||||
└───────────────▲───────────────────────────┬─────────────────┘
|
||||
bound methods runtime.EventsEmit
|
||||
┌───────────────┴───────────────────────────▼─────────────────┐
|
||||
│ desktop/app.go App (bound) + eventSink (event.Sink) │
|
||||
│ desktop/main.go Wails options, window, embed frontend/dist │
|
||||
└───────────────▲───────────────────────────┬─────────────────┘
|
||||
commands │ │ typed event stream
|
||||
┌───────────────┴────────────────────────────▼────────────────┐
|
||||
│ internal/boot.Build → internal/control.Controller (kernel) │
|
||||
│ (same assembly the CLI uses: providers, tools, gate, …) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ webview (React + TS, Vite) — desktop/frontend-next │
|
||||
│ fetch("/api/…") · EventSource("/api/events") │
|
||||
└───────────────▲────────────────────────────┬─────────────────┘
|
||||
│ │
|
||||
┌───────────────┴────────────────────────────▼─────────────────┐
|
||||
│ desktop/next main.go │
|
||||
│ isAPIPath / isHubPath ──▶ serve.Server (kernel HTTP mux) │
|
||||
│ everything else ──▶ frontendAssets() (the SPA) │
|
||||
└───────────────▲────────────────────────────┬─────────────────┘
|
||||
│ │
|
||||
┌───────────────┴────────────────────────────▼─────────────────┐
|
||||
│ internal/boot.Build → internal/control.Controller (kernel) │
|
||||
│ (same assembly the CLI uses: providers, tools, gate, …) │
|
||||
└───────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Because the SPA speaks HTTP rather than `window.go.*`, `desktop/next` stays
|
||||
thin: it owns the window, the platform glue (dock icon, file drop, window fit),
|
||||
and updates. Anything a frontend needs is a kernel route, and
|
||||
`next/route_parity_test.go` fails if the shell routes one of them to the assets.
|
||||
|
||||
## Layout
|
||||
|
||||
| Path | What it is |
|
||||
| --- | --- |
|
||||
| `next/` | the Wails shell: window, routing, updates, platform glue |
|
||||
| `frontend-next/` | the SPA it serves |
|
||||
| `internal/update/` | manifest, download, verify, apply — shared by the shell and the helper |
|
||||
| `cmd/update-helper/` | the elevated half of an update (dpkg on Linux, versioned install on Windows) |
|
||||
| `cmd/studio-manifest/`, `cmd/sign/`, `cmd/windows-resource/` | release-side tools |
|
||||
| `third_party/go-webview2/` | vendored fork; see below |
|
||||
|
||||
## Why a nested module
|
||||
|
||||
`desktop/` is its own Go module (`module reasonix/desktop`, `replace reasonix =>
|
||||
../`). That keeps the CGO + WebKit desktop build entirely separate from the CLI's
|
||||
../`). That keeps the CGO + WebKit build entirely separate from the CLI's
|
||||
`CGO_ENABLED=0` single-static-binary guarantee: the parent module's `go build /
|
||||
vet / test ./...` skip this directory, while the import path stays under
|
||||
`reasonix/` so it can still import the `reasonix/internal/*` kernel.
|
||||
|
||||
## The vendored WebView2 fork
|
||||
|
||||
`replace github.com/wailsapp/go-webview2 => ./third_party/go-webview2` carries
|
||||
patches upstream does not have: mixed-DPI monitor-scale detection (#5862),
|
||||
`--no-proxy-server` so the loopback UI ignores stale system proxies, and native
|
||||
renderer-failure recovery. `next/webview2_patch_test.go` reads the fork's AST
|
||||
and fails if a Wails bump silently drops any of them.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go (matches the parent module).
|
||||
- Node 24+ and **pnpm 10** (`npm install -g pnpm@10`).
|
||||
- Wails CLI matching the library: run `make wails-install` from the repository
|
||||
root. The target reads the shared `.wails-version` pin.
|
||||
- Platform webview libs: macOS ships WebKit; Windows needs the Edge **WebView2**
|
||||
runtime; Linux needs `libgtk-3-dev` plus WebKitGTK. The default build links
|
||||
against **WebKitGTK 4.0**; distros that only ship **4.1** (Fedora 40+, Ubuntu
|
||||
24.04+, Arch) build with `-tags webkit2_41` — see [Build](#build). Run
|
||||
`wails doctor` to verify.
|
||||
runtime; Linux needs `libgtk-3-dev` and `libwebkit2gtk-4.1-dev` (4.0 is gone
|
||||
from Ubuntu 24.04+ and Fedora 40+, so Linux builds always carry the
|
||||
`webkit2_41` tag).
|
||||
|
||||
## Develop
|
||||
No Wails CLI is required — `scripts/studio-build.sh` drives `go build` directly.
|
||||
|
||||
## Running it
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
wails dev # hot-reloads Go + frontend (Vite dev server)
|
||||
make studio # build the SPA + shell and launch (macOS: inside an .app bundle)
|
||||
```
|
||||
|
||||
Frontend-only iteration without the Go side:
|
||||
On macOS that bundle is not optional: LaunchServices only treats a bundled
|
||||
process as a real GUI app, and a bare binary makes native panels — "Add a
|
||||
folder…" — open and close in the same beat.
|
||||
|
||||
For frontend iteration, run the kernel and point Vite at it:
|
||||
|
||||
```sh
|
||||
cd desktop/frontend
|
||||
pnpm install
|
||||
pnpm dev # opens in a plain browser; bridge.ts uses the dev mock
|
||||
go run ./cmd/reasonix serve # from the repository root
|
||||
cd desktop/frontend-next && pnpm install && pnpm dev # :5273, proxies API paths
|
||||
```
|
||||
|
||||
In a plain browser the native bindings are absent, so `bridge.ts` falls back to a
|
||||
**mock** that streams a canned turn (text + one `edit_file` tool call) through the
|
||||
exact same event contract — so layout, streaming, markdown, tool cards, and the
|
||||
diff seam can all be built without rebuilding Go.
|
||||
|
||||
## Test
|
||||
|
||||
The desktop package is a nested Go module, so parent `go test ./...` does not run
|
||||
it. Use the full lane before merging desktop changes, and the short lane for fast
|
||||
local feedback:
|
||||
## Building and testing
|
||||
|
||||
```sh
|
||||
make desktop-test # cd desktop && go test .
|
||||
make desktop-test-short # skips slow desktop integration/e2e checks
|
||||
bash scripts/studio-build.sh --shell-only # compile the shell for this host
|
||||
bash scripts/studio-build.sh darwin/arm64 v0.1.0 # full artifact into dist/
|
||||
|
||||
make studio-test # go test ./... for this module
|
||||
cd frontend-next && pnpm typecheck && pnpm build
|
||||
```
|
||||
|
||||
To find the next bottleneck, rank individual test cases from the JSON stream:
|
||||
|
||||
```sh
|
||||
make desktop-test-times
|
||||
# or: cd desktop && go test -count=1 -json . | python3 ../scripts/desktop-test-times.py
|
||||
```
|
||||
|
||||
### Frontend UI review checklist
|
||||
|
||||
For anchored menus, dropdowns, tooltips, and other portaled UI, review both the
|
||||
component code and the CSS positioning contract:
|
||||
|
||||
- If a component uses `createPortal` plus `getBoundingClientRect()`, it must
|
||||
handle scrollable ancestors, window resize, and `visualViewport` changes.
|
||||
- Add a focused regression test when changing shared positioning primitives such
|
||||
as `AnchoredPopover`, not only the specific menu that exposed the bug.
|
||||
- Exercise at least one scrollable container path, such as Settings content, when
|
||||
manually checking dropdown or popover changes.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
cd desktop
|
||||
wails build # → build/bin/Reasonix(.app/.exe)
|
||||
```
|
||||
|
||||
**Linux on WebKitGTK 4.1 only** (Fedora 40+, Ubuntu 24.04+, Arch — no
|
||||
`webkit2gtk-4.0` package): pass the Wails build tag so cgo links against 4.1.
|
||||
|
||||
```sh
|
||||
wails build -tags webkit2_41
|
||||
wails dev -tags webkit2_41 # same tag for hot-reload
|
||||
```
|
||||
|
||||
Fedora deps: `sudo dnf install webkit2gtk4.1-devel gtk3-devel`.
|
||||
|
||||
`frontend/dist` is generated by the build (it's git-ignored except for a
|
||||
`.gitkeep` that keeps the Go `//go:embed all:frontend/dist` compilable on a fresh
|
||||
checkout). A bare `go build` without a prior `pnpm build` produces a blank window.
|
||||
|
||||
## Releases & auto-update
|
||||
|
||||
Desktop releases ride their own tag namespace, `desktop-v<semver>` (plain `v*`
|
||||
tags are the CLI release). Pushing one triggers `.github/workflows/release-desktop.yml`,
|
||||
which builds on a native runner per platform (Wails can't cross-compile a
|
||||
CGO/WebKit binary), packages each artifact, signs it with minisign, generates a
|
||||
`latest.json` manifest, publishes a GitHub release, marks the desktop release as
|
||||
GitHub's repository-wide `Latest`, mirrors everything to R2, and attaches the
|
||||
current desktop manifest to the matching CLI release for old clients that still
|
||||
ask GitHub's repository-wide `latest` release for it.
|
||||
The Linux artifact links against WebKitGTK 4.1 (`-tags webkit2_41`), so it needs
|
||||
`libwebkit2gtk-4.1-0` at runtime — present by default on Ubuntu 22.04+, Fedora 40+.
|
||||
|
||||
```sh
|
||||
git tag desktop-v1.1.0 && git push origin desktop-v1.1.0
|
||||
```
|
||||
|
||||
The app checks `latest.json` on startup (R2 first, then the
|
||||
`crash.reasonix.io` desktop release gateway) and shows an update banner when a
|
||||
newer version is published; **Settings → Software update** has a manual check.
|
||||
The gateway resolves only the desktop `desktop-v*` release line and never uses
|
||||
GitHub's repository-wide `/releases/latest` shortcut, so updater behavior does
|
||||
not depend on homepage badge semantics. Self-update behavior by platform:
|
||||
|
||||
- **Linux portable (`.tar.gz`)** — download, verify the minisign signature, replace
|
||||
the binaries in the install directory, and relaunch through Guard. No elevation.
|
||||
- **Linux Debian/Ubuntu (`.deb`)** — download the signed `.deb`, request administrator
|
||||
authorization via Polkit (`pkexec`), re-verify and install with `apt-get
|
||||
--only-upgrade`, then relaunch through Guard. The first build that ships the
|
||||
update helper and Polkit policy is a one-time bootstrap: existing `.deb` users
|
||||
should overwrite-install once with
|
||||
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required). After
|
||||
that, in-app authorized updates work. If Polkit/`pkexec` is unavailable, use
|
||||
the same manual command. Failed installs leave the running app intact so you
|
||||
can retry; successful installs are managed by apt/dpkg and are not auto-downgraded.
|
||||
- **Windows** — download, verify the minisign signature, then run the per-user
|
||||
NSIS installer (no admin rights needed).
|
||||
- **macOS** — *not* self-updating yet. The build is unsigned/un-notarized, so an
|
||||
in-place swap would be blocked by Gatekeeper; the banner links to the download
|
||||
page for a manual update instead.
|
||||
|
||||
### Code signing — first launch
|
||||
|
||||
- **Windows** — stable builds carry an Authenticode signature (SignPath, approved
|
||||
per release; `release-desktop.yml` verifies every payload binary through
|
||||
`scripts/verify-windows-authenticode.ps1` and fails the release otherwise). A
|
||||
brand-new version can still show SmartScreen until the signature accumulates
|
||||
reputation: *More info → Run anyway*.
|
||||
- **macOS** — still unsigned and un-notarized. Open
|
||||
`Reasonix-darwin-universal.dmg`, drag Reasonix into Applications, then clear the
|
||||
quarantine attribute when Gatekeeper reports the app "is damaged" or is from an
|
||||
unidentified developer:
|
||||
```sh
|
||||
xattr -dr com.apple.quarantine /Applications/Reasonix.app
|
||||
```
|
||||
This is also why macOS has no in-place self-update: the swap would be blocked.
|
||||
Adding a Developer ID certificate flips the release workflow's `HAS_APPLE_CERT`
|
||||
gate to the signed path and removes both.
|
||||
|
||||
### Verifying a download
|
||||
|
||||
Artifacts are signed with minisign (public key ID `AF12CA46F4A9EBB0`). The `.minisig`
|
||||
signature sits next to each artifact in the release; verify with the
|
||||
[minisign](https://jedisct1.github.io/minisign/) CLI:
|
||||
|
||||
```sh
|
||||
minisign -Vm Reasonix-darwin-arm64.zip \
|
||||
-P RWSw66n0RsoSr6Zhh6qt5YO95YkpCayTOCMFVDNUQSjJYwxoYngNVBSq
|
||||
```
|
||||
|
||||
## Editor seams and workspace file previews
|
||||
|
||||
Code and diff rendering go through two components with stable prop contracts and
|
||||
lazy boundaries, so heavier viewers stay out of the initial bundle. `CodeViewer`
|
||||
keeps the compact highlighted viewer for chat, Markdown, and tool output, while
|
||||
workspace file previews opt into the searchable line-number viewer:
|
||||
|
||||
| Component | Props | Default impl | Upgrade |
|
||||
|---|---|---|---|
|
||||
| `components/CodeViewer.tsx` | `EditorProps` | `editors/HljsCode.tsx`; `editors/LineNumberCode.tsx` when `showLineNumbers` is enabled | extend the implementation selection for Monaco or CodeMirror |
|
||||
| `components/DiffView.tsx` | `DiffProps` | `editors/HljsDiff.tsx` (highlighted LCS/unified diff) | swap for `editors/MonacoDiff` or `editors/CodeMirrorMerge` |
|
||||
|
||||
```sh
|
||||
# Monaco
|
||||
pnpm add @monaco-editor/react monaco-editor
|
||||
# or CodeMirror 6
|
||||
pnpm add @uiw/react-codemirror @codemirror/lang-javascript @codemirror/merge
|
||||
```
|
||||
|
||||
Then add `editors/MonacoCode.tsx` (default-export a component taking
|
||||
`EditorProps`) and update the implementation selection in `CodeViewer.tsx`.
|
||||
`ToolCard` already routes `edit_file` calls' `old_string`/`new_string` through
|
||||
`DiffView`, and `Markdown` routes fenced code blocks through `CodeViewer`, so
|
||||
both seams light up everywhere at once.
|
||||
|
||||
`WorkspacePanel` passes `showLineNumbers` for text-file previews. The resulting
|
||||
viewer provides a line-number gutter, viewer-scoped Ctrl/Cmd+F search with case
|
||||
and whole-word options, copy support, and virtualized rendering above 100 lines.
|
||||
Search marks are applied only to visible rows so query input does not rebuild the
|
||||
entire highlighted document. Files above 512 KiB or 20,000 lines keep line
|
||||
numbers, search, copy, and virtualization but use escaped plain text instead of
|
||||
syntax highlighting. Workspace files are previewed up to 2 MiB; larger files
|
||||
display the first 2 MiB with a localized truncation notice.
|
||||
|
||||
## Multi-platform adaptation
|
||||
|
||||
Wails is the right shell for a Go kernel (no sidecar), but a Go+webview stack uses
|
||||
the **native** webview per OS, so the rough edges are platform-specific. What's
|
||||
handled here, and what to reach for if a target misbehaves:
|
||||
|
||||
- **Linux / WebKitGTK** is the one real pain point — rendering varies by distro &
|
||||
GPU driver. `main.go` keeps `WebviewGpuPolicy: OnDemand` when a DRI render node
|
||||
is usable, and falls back to `Never` for xrdp/headless/software-rendered sessions
|
||||
that cannot access `/dev/dri`. If artifacts persist, launch with
|
||||
`WEBKIT_DISABLE_COMPOSITING_MODE=1`. Test on at least one GTK target before release;
|
||||
the CSS deliberately avoids `backdrop-filter`/blur (slow & inconsistent there).
|
||||
- **Wayland + NVIDIA**: On KDE Plasma Wayland with NVIDIA GPUs, WebKitGTK can
|
||||
crash at startup (`Error 71: Protocol error`) due to an upstream WebKit
|
||||
explicit-sync bug (WebKit #280210, #317089, NVIDIA/egl-wayland #179).
|
||||
Reasonix automatically sets `__NV_DISABLE_EXPLICIT_SYNC=1` when it detects
|
||||
Wayland + NVIDIA GPU. To opt out, set `__NV_DISABLE_EXPLICIT_SYNC=0`.
|
||||
Alternative fallbacks: `WEBKIT_DISABLE_DMABUF_RENDERER=1` (poor performance)
|
||||
or `GDK_BACKEND=x11` (forces XWayland).
|
||||
- **Windows / WebView2** — `Theme: SystemDefault` follows the OS light/dark
|
||||
setting; the installer embeds the WebView2 bootstrapper. Canary builds disable
|
||||
WebView2 GPU acceleration by default to smoke-test blank-window reports; set
|
||||
`REASONIX_DISABLE_WEBVIEW2_GPU=1` or `0` to force the fallback on or off. The
|
||||
older `REASONIX_DESKTOP_DISABLE_WEBVIEW2_GPU` name remains accepted. The WebView2 shell always uses a direct connection for embedded assets
|
||||
and loopback remote-workspace pages; provider and other outbound traffic keeps
|
||||
using Reasonix's own proxy configuration. Remote Markdown images are fetched
|
||||
by the Go backend with the same proxy settings and re-served from the local
|
||||
asset origin, so WebView2 never bypasses the configured proxy for them. Image
|
||||
hosts must resolve locally to public addresses; direct, HTTP(S)-proxy, and
|
||||
SOCKS-proxy connections are pinned to those vetted IPs while preserving the
|
||||
original Host and TLS SNI. If the DOM is still not ready after 15 seconds, the
|
||||
hidden startup window is shown with a native recovery prompt.
|
||||
- **macOS / WebKit** — inset/hidden title bar (`TitleBarHiddenInset`); the CSS
|
||||
marks the top bar as an OS drag region (`--wails-draggable: drag`) and leaves
|
||||
room for the traffic lights.
|
||||
- **Theming** — colors are CSS variables gated on `prefers-color-scheme`, which all
|
||||
three webviews honor, so the UI follows the OS theme without native glue.
|
||||
- **Fonts / offline** — system font stack only; no web-font fetches, so first paint
|
||||
is instant and identical offline.
|
||||
- **First paint** — the window background is set to the dark shell color so there's
|
||||
no white flash before CSS loads (most visible on WebKitGTK).
|
||||
|
||||
## Files
|
||||
|
||||
```
|
||||
desktop/
|
||||
main.go Wails options, window, embed frontend/dist
|
||||
app.go App (bound command surface) + eventSink (event.Sink → webview)
|
||||
wire.go event.Event → JSON wire form (mirrors internal/serve/wire.go)
|
||||
wails.json Wails project config (pnpm install/build/dev)
|
||||
frontend/
|
||||
src/
|
||||
lib/
|
||||
types.ts wire contract (mirrors wire.go)
|
||||
bridge.ts window.go/window.runtime wrapper + browser dev mock
|
||||
useController.ts event-stream reducer + command surface (the hook)
|
||||
components/
|
||||
Transcript, Message, ToolCard, Composer, ApprovalModal, ContextGauge,
|
||||
Markdown, CodeViewer, DiffView
|
||||
editors/ PlainCode, PlainDiff ← editor seam impls (swap targets)
|
||||
```
|
||||
|
||||
## Telemetry
|
||||
|
||||
The desktop app sends one anonymous ping per launch to `crash.reasonix.io`:
|
||||
a random anonymous install id (generated locally and not an account id), app
|
||||
version, OS, architecture, Windows build/revision or bounded Linux
|
||||
distribution/kernel/session facts, and Web Runtime/GPU mode. When the previous
|
||||
process ended abnormally, the next normal launch may also send a bounded native
|
||||
diagnostic (lifecycle phase, symbolized stack, WebView2 or WebKitGTK Runtime,
|
||||
process reason/exit code/recovery fields, window failure kind, and coarse device
|
||||
facts).
|
||||
Panic values are removed and paths/secrets are scrubbed before the report is
|
||||
queued. The install id is attached only while sending and is not stored in a
|
||||
pending crash file. It never includes conversations, account data, API keys,
|
||||
file contents, usernames, hostnames, GPU driver details, or full local paths.
|
||||
|
||||
Opt out any time: Settings > Updates > "Anonymous usage ping", or set
|
||||
`telemetry = false` under `[desktop]` in the global config. Dev builds
|
||||
never ping or upload queued native diagnostics. Frontend crash and
|
||||
performance-pressure reports remain separate and are sent only when the user
|
||||
clicks "Send report" on the diagnostic UI.
|
||||
|
||||
Aggregate quality metrics are also enabled by default and can be disabled from
|
||||
Settings > Updates > "Share aggregate quality metrics", or by setting
|
||||
`metrics = false` under `[desktop]`. These metrics are anonymous signal/bucket
|
||||
counts, lifecycle/window failure buckets, and preference buckets; they never
|
||||
include conversations, prompts, keys, paths, base URLs, or file contents.
|
||||
Linux additionally builds a `.deb`; it is what makes Studio self-updating there,
|
||||
because the shared apply path stages single files and Studio ships a binary plus
|
||||
an SPA tree. See `docs/STUDIO_RELEASE.md`.
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
)
|
||||
|
||||
func TestSaveTabSessionMetaReplacesStaleAgentPreset(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
meta, err := agent.EnsureBranchMeta(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
meta.AgentPreset = boot.AgentPresetBalanced
|
||||
meta.TokenMode = boot.TokenModeFull
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(path, meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := saveTabSessionMetaSnapshot(tabSessionMetaSnapshot{path: path, tokenMode: boot.TokenModeDelivery}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, ok, err := agent.LoadBranchMeta(path)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
if got.AgentPreset != boot.AgentPresetDelivery || got.TokenMode != boot.TokenModeDelivery {
|
||||
t.Fatalf("persisted role = preset:%q tokenMode:%q, want delivery dual-write", got.AgentPreset, got.TokenMode)
|
||||
}
|
||||
if restored := tabSessionProfileFromMeta(path, got).tokenMode; restored != boot.TokenModeDelivery {
|
||||
t.Fatalf("restored tokenMode = %q, want delivery", restored)
|
||||
}
|
||||
}
|
||||
@@ -1,672 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
type stubProvider struct{}
|
||||
|
||||
func (stubProvider) Name() string { return "stub" }
|
||||
|
||||
func (stubProvider) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
||||
ch := make(chan provider.Chunk, 1)
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func controllerWithContent(t *testing.T, path string) *control.Controller {
|
||||
t.Helper()
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "remember this turn"})
|
||||
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "acknowledged"})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: ag, SessionDir: filepath.Dir(path), SessionPath: path, Sink: event.Discard})
|
||||
}
|
||||
|
||||
func waitForFile(t *testing.T, path, want string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), want) {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("session file %q never contained %q", path, want)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdle(t *testing.T, tab *WorkspaceTab) {
|
||||
t.Helper()
|
||||
waitForAutosaveIdleWithin(t, tab, 2*time.Second)
|
||||
}
|
||||
|
||||
func waitForAutosaveIdleWithin(t *testing.T, tab *WorkspaceTab, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
tab.saveMu.Lock()
|
||||
idle := !tab.saving && !tab.saveAgain
|
||||
tab.saveMu.Unlock()
|
||||
if idle {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("autosave loop did not become idle")
|
||||
}
|
||||
|
||||
func appWithTab(t *testing.T, path string) (*App, *WorkspaceTab) {
|
||||
t.Helper()
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "test_tab",
|
||||
Ctrl: ctrl,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: "",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: nil}
|
||||
a := &App{
|
||||
tabs: map[string]*WorkspaceTab{"test_tab": tab},
|
||||
activeTabID: "test_tab",
|
||||
}
|
||||
tab.sink.app = a
|
||||
return a, tab
|
||||
}
|
||||
|
||||
// TestTurnDonePersistsSession proves a completed turn is written to disk without
|
||||
// any explicit Snapshot call — the desktop autosave the data-loss fix adds. A
|
||||
// nil sink ctx (no webview) must not disable persistence.
|
||||
func TestTurnDonePersistsSession(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
_, tab := appWithTab(t, path)
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
// TestNonTurnDoneDoesNotPersist confirms only TurnDone triggers a save, so the
|
||||
// per-token event storm doesn't thrash the disk.
|
||||
func TestNonTurnDoneDoesNotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.Text, Text: "tok"})
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("a non-TurnDone event wrote the session file (err=%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestScheduleSnapshotCoalesces hammers the scheduler concurrently to prove the
|
||||
// single-flight loop neither panics nor drops the final write.
|
||||
func TestScheduleSnapshotCoalesces(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range 64 {
|
||||
wg.Go(func() {
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
waitForFile(t, path, "acknowledged")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
}
|
||||
|
||||
func TestAutosaveFailureRetriesAndRecoversOnNextTurnDone(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
_ = a
|
||||
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForAutosaveIdleWithin(t, tab, 5*time.Second)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures := tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures == 0 {
|
||||
t.Fatal("autosave failure should be recorded and retried")
|
||||
}
|
||||
if info, err := os.Stat(path); err != nil || !info.IsDir() {
|
||||
t.Fatalf("blocked session path should still be the directory, info=%v err=%v", info, err)
|
||||
}
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatalf("remove blocked dir: %v", err)
|
||||
}
|
||||
tab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "remember this turn")
|
||||
waitForAutosaveIdle(t, tab)
|
||||
|
||||
tab.saveMu.Lock()
|
||||
failures = tab.saveFailures
|
||||
tab.saveMu.Unlock()
|
||||
if failures != 0 {
|
||||
t.Fatalf("autosave failures after recovery = %d, want 0", failures)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryUpdatesTabAndProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
originalTopic := "topic_original"
|
||||
if err := setTopicTitle("", originalTopic, "Original"); err != nil {
|
||||
t.Fatalf("set original topic title: %v", err)
|
||||
}
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(originalPath, agent.BranchMeta{
|
||||
Scope: "global",
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
Preview: "first",
|
||||
Turns: 2,
|
||||
SchemaVersion: agent.BranchMetaCountsVersion,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveBranchMeta original: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: originalTopic,
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
|
||||
if err := tab.Ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("Snapshot: %v", err)
|
||||
}
|
||||
recoveryPath := tab.Ctrl.SessionPath()
|
||||
if recoveryPath == "" || recoveryPath == originalPath {
|
||||
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
|
||||
}
|
||||
if tab.SessionPath != recoveryPath {
|
||||
t.Fatalf("tab session path = %q, want recovery path %q", tab.SessionPath, recoveryPath)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].ID != tab.ID {
|
||||
t.Fatalf("saved tabs = %+v, want recovered tab %q", saved.Tabs, tab.ID)
|
||||
}
|
||||
if got := saved.Tabs[0].SessionPath; got != recoveryPath {
|
||||
t.Fatalf("saved tab session path = %q, want recovery path %q", got, recoveryPath)
|
||||
}
|
||||
if tab.TopicID != originalTopic {
|
||||
t.Fatalf("tab topic ID = %q, want original topic %q", tab.TopicID, originalTopic)
|
||||
}
|
||||
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
|
||||
}
|
||||
if !meta.Recovered || meta.TopicID != tab.TopicID || meta.TopicTitle != tab.TopicTitle {
|
||||
t.Fatalf("recovery meta = %+v, tab topic=%q/%q", meta, tab.TopicID, tab.TopicTitle)
|
||||
}
|
||||
tabMeta := app.tabMeta(tab, true)
|
||||
if !tabMeta.Recovered || tabMeta.RecoveryDigest != meta.RecoveryDigest || tabMeta.RecoveryParentID != string(meta.ParentID) {
|
||||
t.Fatalf("tab recovery meta = %+v, want digest %q parent %q", tabMeta, meta.RecoveryDigest, meta.ParentID)
|
||||
}
|
||||
nodes := app.ListProjectTree()
|
||||
foundOriginal := false
|
||||
var walk func([]ProjectNode)
|
||||
walk = func(list []ProjectNode) {
|
||||
for _, node := range list {
|
||||
if node.Recovered {
|
||||
t.Fatalf("project tree should hide recovery metadata, got node %+v", node)
|
||||
}
|
||||
if node.TopicID == originalTopic {
|
||||
foundOriginal = true
|
||||
}
|
||||
walk(node.Children)
|
||||
}
|
||||
}
|
||||
walk(nodes)
|
||||
if !foundOriginal {
|
||||
t.Fatalf("project tree did not include original topic %q: %#v", originalTopic, nodes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSnapshotConflictRecoveryRequiresRecoveryLease(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
root := globalTabWorkspaceRoot()
|
||||
dir := desktopSessionDir(root)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
originalPath := filepath.Join(dir, "session.jsonl")
|
||||
current := agent.NewSession("sys")
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
|
||||
if err := current.Save(originalPath); err != nil {
|
||||
t.Fatalf("Save current: %v", err)
|
||||
}
|
||||
|
||||
staleSess := agent.NewSession("sys")
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
|
||||
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
|
||||
recovery, err := staleSess.SaveRecoveryBranch(agent.RecoveryBranchOptions{
|
||||
OriginalPath: originalPath,
|
||||
BranchMeta: agent.BranchMeta{
|
||||
Name: agent.RecoveryBranchDefaultName,
|
||||
Scope: "global",
|
||||
TopicID: "topic_recovery",
|
||||
TopicTitle: "Recovery",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SaveRecoveryBranch: %v", err)
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(recovery.Path)
|
||||
if err != nil {
|
||||
t.Fatalf("TryAcquireSessionLease recovery: %v", err)
|
||||
}
|
||||
defer lease.Release()
|
||||
|
||||
staleExec := agent.New(stubProvider{}, tool.NewRegistry(), staleSess, agent.Options{}, event.Discard)
|
||||
runtimeEvents := make(chan runtimeEventEnvelope, 4)
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{},
|
||||
detachedSessions: map[string]*WorkspaceTab{},
|
||||
activeTabID: "recovery_tab",
|
||||
}
|
||||
app.runtimeEvents.emit = func(ctx context.Context, name string, payload ...any) {
|
||||
runtimeEvents <- runtimeEventEnvelope{
|
||||
ctx: ctx,
|
||||
name: name,
|
||||
payload: append([]any(nil), payload...),
|
||||
}
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "recovery_tab",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: root,
|
||||
TopicID: "topic_original",
|
||||
TopicTitle: "Original",
|
||||
SessionPath: originalPath,
|
||||
Ready: true,
|
||||
model: "test-model",
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
tab.Ctrl = control.New(control.Options{
|
||||
Executor: staleExec,
|
||||
SessionDir: dir,
|
||||
SessionPath: originalPath,
|
||||
Label: "test",
|
||||
Sink: tab.sink,
|
||||
SessionRecoveryMeta: app.tabSessionRecoveryMeta(tab),
|
||||
OnSessionRecovered: app.handleTabSessionRecovered(tab),
|
||||
})
|
||||
app.tabs[tab.ID] = tab
|
||||
app.mu.Lock()
|
||||
app.saveTabsLocked()
|
||||
app.mu.Unlock()
|
||||
|
||||
err = tab.Ctrl.Snapshot()
|
||||
if !errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
t.Fatalf("Snapshot err = %v, want ErrSessionLeaseHeld", err)
|
||||
}
|
||||
if got := tab.Ctrl.SessionPath(); got != originalPath {
|
||||
t.Fatalf("controller session path = %q, want original %q", got, originalPath)
|
||||
}
|
||||
if tab.SessionPath != originalPath {
|
||||
t.Fatalf("tab session path = %q, want original %q", tab.SessionPath, originalPath)
|
||||
}
|
||||
if tab.TopicID != "topic_original" {
|
||||
t.Fatalf("tab topic ID = %q, want original topic", tab.TopicID)
|
||||
}
|
||||
saved := loadTabsFile()
|
||||
if len(saved.Tabs) != 1 || saved.Tabs[0].SessionPath != originalPath {
|
||||
t.Fatalf("saved tabs after failed recovery = %+v, want original path %q", saved.Tabs, originalPath)
|
||||
}
|
||||
|
||||
deadline := time.After(time.Second)
|
||||
for {
|
||||
select {
|
||||
case emitted := <-runtimeEvents:
|
||||
if emitted.name != "session:recovery-failed" {
|
||||
continue
|
||||
}
|
||||
if len(emitted.payload) != 1 {
|
||||
t.Fatalf("session:recovery-failed payload count = %d, want 1", len(emitted.payload))
|
||||
}
|
||||
failed, ok := emitted.payload[0].(sessionRecoveryFailedEvent)
|
||||
if !ok {
|
||||
t.Fatalf("session:recovery-failed payload type = %T, want sessionRecoveryFailedEvent", emitted.payload[0])
|
||||
}
|
||||
if failed.Reason != "lease_held" {
|
||||
t.Fatalf("session:recovery-failed reason = %q, want lease_held", failed.Reason)
|
||||
}
|
||||
return
|
||||
case <-deadline:
|
||||
t.Fatal("session:recovery-failed event was not emitted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetActiveTabBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, _ := appWithTab(t, path)
|
||||
a.tabs["target_tab"] = &WorkspaceTab{
|
||||
ID: "target_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
a.tabOrder = []string{"test_tab", "target_tab"}
|
||||
|
||||
err := a.SetActiveTab("target_tab")
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching tabs") {
|
||||
t.Fatalf("SetActiveTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if a.activeTabID != "test_tab" {
|
||||
t.Fatalf("active tab = %q, want original tab after failed save", a.activeTabID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebindSessionBlocksWhenCurrentSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
target := filepath.Join(t.TempDir(), "target.jsonl")
|
||||
sess := agent.NewSession("system")
|
||||
sess.Add(provider.Message{Role: provider.RoleUser, Content: "target prompt"})
|
||||
if err := sess.Save(target); err != nil {
|
||||
t.Fatalf("save target: %v", err)
|
||||
}
|
||||
loaded, err := agent.LoadSession(target)
|
||||
if err != nil {
|
||||
t.Fatalf("load target: %v", err)
|
||||
}
|
||||
|
||||
err = a.rebindTabToLoadedSessionPath(tab, target, loaded)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before switching sessions") {
|
||||
t.Fatalf("rebind error = %v, want persistence failure", err)
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed save: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabNoResurrectionFromAutosave is the regression test for #4384.
|
||||
// It proves that after CloseTab returns, the per-turn autosave goroutine can no
|
||||
// longer write the session file — even when it is in flight at the moment the
|
||||
// tab is closed. Pre-fix, the loop held a raw *WorkspaceTab pointer and a
|
||||
// captured session path, so its Snapshot() call landed after DeleteSession
|
||||
// trashed the file, "resurrecting" it.
|
||||
func TestCloseTabNoResurrectionFromAutosave(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "session.jsonl")
|
||||
|
||||
doomed, doomedTab := appWithTab(t, path)
|
||||
// CloseTab needs >1 tab and mutates activeTabID, so add a survivor tab.
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: doomed}
|
||||
doomed.tabs["survivor_tab"] = survivor
|
||||
doomed.activeTabID = "test_tab"
|
||||
|
||||
// Write the session file once via the autosave loop, then wait for idle so
|
||||
// the next TurnDone reliably kicks off a fresh loop.
|
||||
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, path, "acknowledged")
|
||||
waitForAutosaveIdle(t, doomedTab)
|
||||
|
||||
// Kick the autosave loop and close the tab in close succession. The loop
|
||||
// will be in flight when CloseTab runs — exactly the #4384 window.
|
||||
doomedTab.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if err := doomed.CloseTab("test_tab"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
|
||||
// CloseTab must have returned only after the autosave loop finished. Remove
|
||||
// the file the way DeleteSession would (move to trash is just a remove here
|
||||
// since we only care that nothing rewrites the original path).
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("remove session file: %v", err)
|
||||
}
|
||||
|
||||
// Give any would-be resurrection a chance to strike. If the autosave loop
|
||||
// were still alive (the bug), the file reappears here.
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("session file resurrected after CloseTab + delete (stat err=%v) — autosave loop not drained", err)
|
||||
}
|
||||
|
||||
// And the controller's session path must be cleared so no future Snapshot
|
||||
// can write either.
|
||||
if got := doomedTab.Ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("controller session path = %q after CloseTab, want empty so snapshots no-op", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloseTabBlocksWhenSessionCannotPersist(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "blocked.jsonl")
|
||||
if err := os.Mkdir(path, 0o755); err != nil {
|
||||
t.Fatalf("mkdir blocked path: %v", err)
|
||||
}
|
||||
a, tab := appWithTab(t, path)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs[survivor.ID] = survivor
|
||||
a.tabOrder = []string{tab.ID, survivor.ID}
|
||||
|
||||
err := a.CloseTab(tab.ID)
|
||||
if err == nil || !strings.Contains(err.Error(), "save current session before closing tab") {
|
||||
t.Fatalf("CloseTab error = %v, want persistence failure", err)
|
||||
}
|
||||
if _, ok := a.tabs[tab.ID]; !ok {
|
||||
t.Fatal("tab was removed even though its session could not be saved")
|
||||
}
|
||||
if tab.Ctrl == nil || tab.Ctrl.SessionPath() != path {
|
||||
t.Fatalf("tab controller/path changed after failed close: ctrl=%v path=%q", tab.Ctrl, tab.currentSessionPath())
|
||||
}
|
||||
}
|
||||
|
||||
// TestCloseTabSurvivorKeepsAutosave ensures the survivor tab is untouched: the
|
||||
// closing/drain logic is per-tab and must not leak to other tabs.
|
||||
func TestCloseTabSurvivorKeepsAutosave(t *testing.T) {
|
||||
doomedPath := filepath.Join(t.TempDir(), "doomed.jsonl")
|
||||
survivorPath := filepath.Join(t.TempDir(), "survivor.jsonl")
|
||||
|
||||
a, _ := appWithTab(t, doomedPath)
|
||||
survivorCtrl := controllerWithContent(t, survivorPath)
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor_tab",
|
||||
Ctrl: survivorCtrl,
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor.sink = &tabEventSink{tabID: survivor.ID, app: a}
|
||||
a.tabs["survivor_tab"] = survivor
|
||||
a.activeTabID = "test_tab"
|
||||
|
||||
survivor.sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
waitForFile(t, survivorPath, "acknowledged")
|
||||
waitForAutosaveIdle(t, survivor)
|
||||
|
||||
if err := a.CloseTab("test_tab"); err != nil {
|
||||
t.Fatalf("CloseTab: %v", err)
|
||||
}
|
||||
|
||||
if got := survivor.Ctrl.SessionPath(); got != survivorPath {
|
||||
t.Fatalf("survivor session path = %q, want %q", got, survivorPath)
|
||||
}
|
||||
if survivor.closing {
|
||||
t.Fatal("survivor tab was marked closing — closing flag leaked across tabs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSessionClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "delete-open.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
tab := &WorkspaceTab{
|
||||
ID: "delete_open",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"delete_open": tab},
|
||||
activeTabID: "delete_open",
|
||||
}
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
|
||||
if err := app.DeleteSession(path); err != nil {
|
||||
t.Fatalf("DeleteSession: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "delete-open.jsonl", "delete-open.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrashTopicClearsRemovedRuntimeSessionPath(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
topicID := "topic_clear_removed_runtime"
|
||||
if err := addProject(projectRoot, ""); err != nil {
|
||||
t.Fatalf("add project: %v", err)
|
||||
}
|
||||
if err := setTopicTitle(projectRoot, topicID, "Clear removed runtime"); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "trash-open-topic.jsonl")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
if err := agent.SaveBranchMeta(path, agent.BranchMeta{
|
||||
CreatedAt: time.Now().Add(-time.Minute),
|
||||
UpdatedAt: time.Now(),
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "trash_open",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectRoot,
|
||||
TopicID: topicID,
|
||||
TopicTitle: "Clear removed runtime",
|
||||
Ready: true,
|
||||
Ctrl: ctrl,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
survivor := &WorkspaceTab{
|
||||
ID: "survivor",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"trash_open": tab, "survivor": survivor},
|
||||
tabOrder: []string{"trash_open", "survivor"},
|
||||
activeTabID: "trash_open",
|
||||
}
|
||||
|
||||
if err := app.TrashTopic(topicID); err != nil {
|
||||
t.Fatalf("TrashTopic: %v", err)
|
||||
}
|
||||
|
||||
if got := ctrl.SessionPath(); got != "" {
|
||||
t.Fatalf("removed topic controller session path = %q, want empty before trash move can race Windows file locks", got)
|
||||
}
|
||||
trashPath := filepath.Join(dir, sessionTrashDir, "trash-open-topic.jsonl", "trash-open-topic.jsonl")
|
||||
if _, err := os.Stat(trashPath); err != nil {
|
||||
t.Fatalf("topic session should be in trash: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"reasonix/internal/appidentity"
|
||||
"reasonix/internal/installlayout"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := appidentity.ApplyToCurrentProcess(); err != nil {
|
||||
println("Warning: apply Windows app identity:", err.Error())
|
||||
}
|
||||
executable, err := os.Executable()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
installRoot, err := installlayout.ResolveInstallRoot(executable)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := appidentity.RepairOwnedShortcuts(installRoot); err != nil {
|
||||
println("Warning: repair Windows shortcut identity:", err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppPlatformReturnsRuntimeGOOS(t *testing.T) {
|
||||
app := NewApp()
|
||||
|
||||
if got := app.Platform(); got != runtime.GOOS {
|
||||
t.Fatalf("Platform() = %q, want %q", got, runtime.GOOS)
|
||||
}
|
||||
}
|
||||
@@ -1,798 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/tool"
|
||||
)
|
||||
|
||||
func carryingController(carried []provider.Message, path string) *control.Controller {
|
||||
sess := &agent.Session{}
|
||||
sess.Replace(carried)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
return control.New(control.Options{Executor: ag, SessionPath: path, Sink: event.Discard})
|
||||
}
|
||||
|
||||
// TestCarriedRebuildsKeepOneSession reproduces issue #2807: a model switch or any
|
||||
// config change rebuilds the controller and carries the conversation forward. Each
|
||||
// rebuild must keep writing to the same file, so a run of them leaves exactly one
|
||||
// history entry — not a new identical duplicate per rebuild.
|
||||
func TestCarriedRebuildsKeepOneSession(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for range 5 {
|
||||
prevPath := ctrl.SessionPath()
|
||||
carried := ctrl.History()
|
||||
ctrl.Close()
|
||||
|
||||
newPath := agent.ContinueSessionPath(prevPath, dir, "model-b")
|
||||
ctrl = carryingController(carried, newPath)
|
||||
if err := ctrl.Snapshot(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
ctrl.Close()
|
||||
|
||||
infos, err := agent.ListSessions(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(infos) != 1 {
|
||||
paths := make([]string, len(infos))
|
||||
for i, s := range infos {
|
||||
paths[i] = filepath.Base(s.Path)
|
||||
}
|
||||
t.Fatalf("after 5 carried rebuilds the history shows %d sessions, want 1: %v", len(infos), paths)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open blank tab rather than creating a second one.
|
||||
|
||||
func TestEnsureBlankTabReusesExistingBlankTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SessionPath == "" {
|
||||
t.Fatal("EnsureBlankTab should pre-create a session path for immediate deletion")
|
||||
}
|
||||
if _, err := os.Stat(first.SessionPath); err != nil {
|
||||
t.Fatalf("pre-created blank session should exist: %v", err)
|
||||
}
|
||||
second, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("EnsureBlankTab created duplicate blank tab: first=%q second=%q", first.ID, second.ID)
|
||||
}
|
||||
if tabs := app.ListTabs(); len(tabs) != 1 {
|
||||
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesPrecreatedBlankBeforeControllerReady(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
if err := os.MkdirAll(globalRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sessionPath := agent.NewSessionPath(desktopSessionDir(globalRoot), "")
|
||||
if err := os.MkdirAll(filepath.Dir(sessionPath), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(sessionPath, nil, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
app.tabs["blank"] = &WorkspaceTab{
|
||||
ID: "blank",
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
SessionPath: sessionPath,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabOrder = []string{"blank"}
|
||||
app.activeTabID = "blank"
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.ID != "blank" {
|
||||
t.Fatalf("EnsureBlankTab created duplicate blank tab %q, want existing pre-created blank", meta.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabReusesIndexedTopicWithEmptyStub(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
globalRoot := globalWorkspaceRoot()
|
||||
dir := desktopSessionDir(globalRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
stubPath := filepath.Join(dir, "empty-stub.jsonl")
|
||||
if err := os.WriteFile(stubPath, nil, 0o644); err != nil {
|
||||
t.Fatalf("write empty stub: %v", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := agent.SaveBranchMetaPreserveUpdated(stubPath, agent.BranchMeta{
|
||||
CreatedAt: now.Add(-time.Minute),
|
||||
UpdatedAt: now,
|
||||
Scope: "global",
|
||||
WorkspaceRoot: globalRoot,
|
||||
TopicID: topic.ID,
|
||||
TopicTitle: defaultTopicTitle,
|
||||
}); err != nil {
|
||||
t.Fatalf("save branch meta: %v", err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused empty topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStoresCreatedAt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
before := time.Now().UnixMilli()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
after := time.Now().UnixMilli()
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
|
||||
createdAt := loadTopicCreatedAt("", meta.TopicID)
|
||||
if createdAt < before || createdAt > after {
|
||||
t.Fatalf("createdAt = %d, want between %d and %d", createdAt, before, after)
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || nodes[0].Kind != "global_folder" || len(nodes[0].Children) != 1 {
|
||||
t.Fatalf("project tree = %#v, want Global with one topic", nodes)
|
||||
}
|
||||
if got := nodes[0].Children[0].CreatedAt; got != createdAt {
|
||||
t.Fatalf("project tree createdAt = %d, want %d", got, createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabRepairsMissingCreatedAtForReusedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
const topicID = "topic_20260704-104018_deadbeef"
|
||||
if err := setTopicTitleWithSource("", topicID, defaultTopicTitle, topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set topic title: %v", err)
|
||||
}
|
||||
if err := prependTopicInProjectsFile("", topicID, false); err != nil {
|
||||
t.Fatalf("prepend topic: %v", err)
|
||||
}
|
||||
if got := loadTopicCreatedAt("", topicID); got != 0 {
|
||||
t.Fatalf("createdAt before reuse = %d, want 0", got)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID != topicID {
|
||||
t.Fatalf("EnsureBlankTab topic = %q, want reused topic %q", meta.TopicID, topicID)
|
||||
}
|
||||
|
||||
expected := time.Date(2026, 7, 4, 10, 40, 18, 0, time.UTC).UnixMilli()
|
||||
if got := loadTopicCreatedAt("", topicID); got != expected {
|
||||
t.Fatalf("repaired createdAt = %d, want %d", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab reuses an already-open project-scoped blank tab.
|
||||
|
||||
func TestEnsureBlankTabCreatesOneBlankPerProject(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.ID != first.ID {
|
||||
t.Fatalf("EnsureBlankTab created duplicate project blank tab: first=%q second=%q", first.ID, second.ID)
|
||||
}
|
||||
if tabs := app.ListTabs(); len(tabs) != 1 {
|
||||
t.Fatalf("ListTabs length = %d, want 1: %+v", len(tabs), tabs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabStartsProjectRuntimeWithCurrentWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
tabA := waitForTabReady(t, app, first.ID)
|
||||
if got := normalizeProjectRoot(tabA.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("project A controller workspace root = %q, want %q", got, normalizeProjectRoot(projectA))
|
||||
}
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
if second.ID == first.ID {
|
||||
t.Fatalf("EnsureBlankTab reused project A tab %q for project B", second.ID)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
|
||||
if got := normalizeProjectRoot(tabB.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("project B controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if !sameDesktopPath(tabB.Ctrl.SessionDir(), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session dir = %q, want %q", tabB.Ctrl.SessionDir(), desktopSessionDir(projectB))
|
||||
}
|
||||
if !sameDesktopPath(filepath.Dir(tabB.Ctrl.SessionPath()), desktopSessionDir(projectB)) {
|
||||
t.Fatalf("project B controller session path = %q, want under %q", tabB.Ctrl.SessionPath(), desktopSessionDir(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("project B system prompt missing current workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("project B system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlankTabSessionPathRejectsOtherProjectWorkspace(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
pathA, err := createEmptySessionFile(desktopSessionDir(projectA), "test-model")
|
||||
if err != nil {
|
||||
t.Fatalf("create project A empty session: %v", err)
|
||||
}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "blank-project-b",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
SessionPath: pathA,
|
||||
}
|
||||
|
||||
if blankTabSessionPathHasNoContent(tab) {
|
||||
t.Fatalf("blank tab treated session %q from project A as reusable for project B %q", pathA, projectB)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
forked, err := app.Fork(turn)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.ID == "" || forked.ID == second.ID {
|
||||
t.Fatalf("forked tab ID = %q, want a fresh tab distinct from %q", forked.ID, second.ID)
|
||||
}
|
||||
forkTab := waitForTabReady(t, app, forked.ID)
|
||||
if got := normalizeProjectRoot(forkTab.WorkspaceRoot); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork tab workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
if got := normalizeProjectRoot(forkTab.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("fork controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(forkTab.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("fork system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("fork system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRewindKeepsProjectWorkspacePrompt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectA := robustTempDir(t)
|
||||
projectB := robustTempDir(t)
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
first, err := app.EnsureBlankTab("project", projectA)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project A): %v", err)
|
||||
}
|
||||
waitForTabReady(t, app, first.ID)
|
||||
|
||||
second, err := app.EnsureBlankTab("project", projectB)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab(project B): %v", err)
|
||||
}
|
||||
tabB := waitForTabReady(t, app, second.ID)
|
||||
ctrl := installStubControllerWithCurrentPrompt(t, app, tabB)
|
||||
turn := submitStubTurnAndWaitForCheckpoint(t, ctrl, "project B turn")
|
||||
|
||||
if err := app.Rewind(turn, "conversation"); err != nil {
|
||||
t.Fatalf("Rewind: %v", err)
|
||||
}
|
||||
if got := normalizeProjectRoot(tabB.Ctrl.WorkspaceRoot()); got != normalizeProjectRoot(projectB) {
|
||||
t.Fatalf("rewound controller workspace root = %q, want %q", got, normalizeProjectRoot(projectB))
|
||||
}
|
||||
sys := systemPromptFrom(tabB.Ctrl.History())
|
||||
if !strings.Contains(sys, "Current workspace: "+strconv.Quote(projectB)) {
|
||||
t.Fatalf("rewound system prompt missing project B workspace %q:\n%s", projectB, sys)
|
||||
}
|
||||
if strings.Contains(sys, "Current workspace: "+strconv.Quote(projectA)) {
|
||||
t.Fatalf("rewound system prompt retained project A workspace %q:\n%s", projectA, sys)
|
||||
}
|
||||
}
|
||||
|
||||
func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *WorkspaceTab) *control.Controller {
|
||||
t.Helper()
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
t.Fatal("tab controller is required")
|
||||
}
|
||||
sys := systemPromptFrom(tab.Ctrl.History())
|
||||
if strings.TrimSpace(sys) == "" {
|
||||
t.Fatal("tab controller did not expose a system prompt")
|
||||
}
|
||||
sessionDir := tab.Ctrl.SessionDir()
|
||||
sessionPath := tab.Ctrl.SessionPath()
|
||||
workspaceRoot := tab.Ctrl.WorkspaceRoot()
|
||||
label := tab.Ctrl.Label()
|
||||
tab.Ctrl.Close()
|
||||
|
||||
sess := agent.NewSession(sys)
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: ag,
|
||||
Executor: ag,
|
||||
SessionDir: sessionDir,
|
||||
SessionPath: sessionPath,
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Label: label,
|
||||
SystemPrompt: sys,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
tab.Ctrl = ctrl
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
func submitStubTurnAndWaitForCheckpoint(t *testing.T, ctrl control.SessionAPI, input string) int {
|
||||
t.Helper()
|
||||
ctrl.SubmitUserTurn(input, input)
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for {
|
||||
checkpoints := ctrl.Checkpoints()
|
||||
if len(checkpoints) > 0 {
|
||||
return checkpoints[len(checkpoints)-1].Turn
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("controller did not record a checkpoint")
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabResetsReusableAutoTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set stale auto title: %v", err)
|
||||
}
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if got := meta.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("reused auto topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != defaultTopicTitle {
|
||||
t.Fatalf("stored title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceAuto {
|
||||
t.Fatalf("title source = %q, want auto", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabPreservesReusableManualTopicTitle(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "Manual title")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
tab := app.createTabEntryWithID("project", projectRoot, topic.ID, "tab1")
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if got := meta.TopicTitle; got != "Manual title" {
|
||||
t.Fatalf("reused manual topic title = %q, want Manual title", got)
|
||||
}
|
||||
if got := loadTopicTitle(projectRoot, topic.ID); got != "Manual title" {
|
||||
t.Fatalf("stored title = %q, want Manual title", got)
|
||||
}
|
||||
if got := loadTopicTitleSource(projectRoot, topic.ID); got != topicTitleSourceManual {
|
||||
t.Fatalf("title source = %q, want manual", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabKeepsActiveTabWhenTitleResetFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("create topic: %v", err)
|
||||
}
|
||||
if err := setTopicTitleWithSource(projectRoot, topic.ID, "Old auto title", topicTitleSourceAuto); err != nil {
|
||||
t.Fatalf("set stale auto title: %v", err)
|
||||
}
|
||||
activeTab := app.createTabEntryWithID("global", globalTabWorkspaceRoot(), "", "active-tab")
|
||||
reusableTab := app.createTabEntryWithID("project", projectRoot, topic.ID, "reusable-tab")
|
||||
app.tabs[activeTab.ID] = activeTab
|
||||
app.tabs[reusableTab.ID] = reusableTab
|
||||
app.tabOrder = []string{activeTab.ID, reusableTab.ID}
|
||||
app.activeTabID = activeTab.ID
|
||||
|
||||
titlePath := topicTitlesPath(projectRoot)
|
||||
if err := os.Remove(titlePath); err != nil {
|
||||
t.Fatalf("remove title file: %v", err)
|
||||
}
|
||||
if err := os.Mkdir(titlePath, 0o755); err != nil {
|
||||
t.Fatalf("replace title file with directory: %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.EnsureBlankTab("project", projectRoot); err == nil {
|
||||
t.Fatal("EnsureBlankTab succeeded, want title reset error")
|
||||
}
|
||||
if got := app.activeTabID; got != activeTab.ID {
|
||||
t.Fatalf("active tab after failed title reset = %q, want %q", got, activeTab.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
||||
// instead of creating a fresh topic, for global scope.
|
||||
|
||||
func TestEnsureBlankTabOpensExistingSidebarBlankTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("global", "", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
if topics := loadProjectsFile().GlobalTopics; len(topics) != 1 {
|
||||
t.Fatalf("global topics length = %d, want 1: %v", len(topics), topics)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab picks up an existing blank topic created in the sidebar
|
||||
// instead of creating a fresh topic, for project scope.
|
||||
|
||||
func TestEnsureBlankTabOpensExistingProjectSidebarBlankTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.TopicID != topic.ID {
|
||||
t.Fatalf("EnsureBlankTab opened topic %q, want existing blank topic %q", meta.TopicID, topic.ID)
|
||||
}
|
||||
var topics []string
|
||||
for _, project := range loadProjectsFile().Projects {
|
||||
if project.Root == projectRoot {
|
||||
topics = project.Topics
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(topics) != 1 {
|
||||
t.Fatalf("project topics length = %d, want 1: %v", len(topics), topics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureBlankTabDoesNotReuseProjectTopicWithSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := robustTempDir(t)
|
||||
app := NewApp()
|
||||
topic, err := app.CreateTopic("project", projectRoot, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTopic: %v", err)
|
||||
}
|
||||
dir := desktopSessionDir(projectRoot)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
existingPath := writeTopicSession(t, dir, "existing.jsonl", topic.ID, defaultTopicTitle, projectRoot)
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("precondition topic session = %q, want %q", got, existingPath)
|
||||
}
|
||||
|
||||
meta, err := app.EnsureBlankTab("project", projectRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == topic.ID {
|
||||
t.Fatalf("EnsureBlankTab reused topic %q even though it already has session %q", topic.ID, existingPath)
|
||||
}
|
||||
if got, _ := app.findTopicSessionForTarget("project", projectRoot, topic.ID); got != existingPath {
|
||||
t.Fatalf("existing topic session changed = %q, want %q", got, existingPath)
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureBlankTab must not reuse a tombstoned topic: the reused ID would flow
|
||||
// into ensureTopicIndexed, whose intentional prepend clears the delete
|
||||
// tombstone and resurrects the topic the user removed.
|
||||
func TestEnsureBlankTabDoesNotReuseTombstonedTopic(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
// Race product on disk: deleted topic whose default title lingered in the
|
||||
// global title map (title-only, absent from GlobalTopics, no sessions).
|
||||
tombstonedID := "topic_tombstone_blank"
|
||||
if err := setTopicTitle("", tombstonedID, defaultTopicTitle); err != nil {
|
||||
t.Fatalf("set lingering title: %v", err)
|
||||
}
|
||||
if err := updateProjectsFile(func(f *desktopProjectFile) (bool, error) {
|
||||
f.DeletedTopics = prependUniqueString(f.DeletedTopics, tombstonedID)
|
||||
return true, nil
|
||||
}); err != nil {
|
||||
t.Fatalf("seed tombstone: %v", err)
|
||||
}
|
||||
|
||||
meta, err := NewApp().EnsureBlankTab("global", "")
|
||||
if err != nil {
|
||||
t.Fatalf("EnsureBlankTab: %v", err)
|
||||
}
|
||||
if meta.TopicID == tombstonedID {
|
||||
t.Fatalf("EnsureBlankTab reused tombstoned topic %q", meta.TopicID)
|
||||
}
|
||||
f := loadProjectsFile()
|
||||
if !containsDesktopString(f.DeletedTopics, tombstonedID) {
|
||||
t.Fatalf("deletedTopics = %#v, tombstone must survive blank-tab creation", f.DeletedTopics)
|
||||
}
|
||||
if containsDesktopString(f.GlobalTopics, tombstonedID) {
|
||||
t.Fatalf("globalTopics = %#v, tombstoned topic must not be re-indexed", f.GlobalTopics)
|
||||
}
|
||||
}
|
||||
|
||||
// NewSession skips the snapshot when the current tab has no real conversation content.
|
||||
|
||||
func TestNewSessionNoopsWhenCurrentTabIsBlank(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := carryingController([]provider.Message{{Role: provider.RoleSystem, Content: "sys"}}, path)
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got != path {
|
||||
t.Fatalf("blank NewSession changed session path = %q, want %q", got, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionUsesFreshTopicIdentity(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
projectRoot := t.TempDir()
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
oldTopicID := "topic_old"
|
||||
oldTopicTitle := "Old topic"
|
||||
oldPath := writeTopicSessionWithPrompt(t, dir, "old.jsonl", oldTopicID, oldTopicTitle, projectRoot, "old prompt", time.Now().Add(-time.Hour))
|
||||
sess := &agent.Session{}
|
||||
sess.Replace([]provider.Message{{Role: provider.RoleUser, Content: "old prompt"}})
|
||||
ag := agent.New(stubProvider{}, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, SessionPath: oldPath, Sink: event.Discard})
|
||||
|
||||
app := NewApp()
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.Scope = "project"
|
||||
tab.WorkspaceRoot = projectRoot
|
||||
tab.TopicID = oldTopicID
|
||||
tab.TopicTitle = oldTopicTitle
|
||||
tab.SessionPath = oldPath
|
||||
app.projectTreeChangedHook = func() {}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == oldTopicID {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from %q", got, oldTopicID)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
newPath := ctrl.SessionPath()
|
||||
if newPath == "" || filepath.Clean(newPath) == filepath.Clean(oldPath) {
|
||||
t.Fatalf("new session path = %q, want fresh path distinct from %q", newPath, oldPath)
|
||||
}
|
||||
if err := os.WriteFile(newPath, []byte(`{"role":"user","content":"new prompt"}`+"\n"), 0o644); err != nil {
|
||||
t.Fatalf("write new session: %v", err)
|
||||
}
|
||||
if !app.maybeAutoTitleTopic(tab) {
|
||||
t.Fatalf("new session should auto-title its fresh topic")
|
||||
}
|
||||
|
||||
oldMeta, ok, err := agent.LoadBranchMeta(oldPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load old meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if oldMeta.TopicID != oldTopicID || oldMeta.TopicTitle != oldTopicTitle {
|
||||
t.Fatalf("old session meta changed after new session auto-title: %+v", oldMeta)
|
||||
}
|
||||
newMeta, ok, err := agent.LoadBranchMeta(newPath)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load new meta: ok=%v err=%v", ok, err)
|
||||
}
|
||||
if newMeta.TopicID != tab.TopicID || newMeta.TopicTitle != "new prompt" {
|
||||
t.Fatalf("new session meta = %+v, want topic %q titled new prompt", newMeta, tab.TopicID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewSessionKeepsFreshRuntimeWhenTopicRepairFails(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
dir := config.SessionDir()
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir sessions: %v", err)
|
||||
}
|
||||
path := agent.NewSessionPath(dir, "model-a")
|
||||
ctrl := controllerWithContent(t, path)
|
||||
app := NewApp()
|
||||
app.projectTreeChangedHook = func() {}
|
||||
app.setTestCtrl(ctrl, "model-a")
|
||||
tab := app.tabs["test"]
|
||||
tab.TopicID = "topic_old"
|
||||
tab.TopicTitle = "Old topic"
|
||||
|
||||
// Block desktopConfigDir-backed topic-index writes without affecting the
|
||||
// session directory, which exercises the post-NewSession repair failure path.
|
||||
if err := os.MkdirAll(filepath.Dir(desktopConfigDir()), 0o755); err != nil {
|
||||
t.Fatalf("mkdir desktop config parent: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(desktopConfigDir(), []byte("not-a-directory"), 0o644); err != nil {
|
||||
t.Fatalf("block desktop config dir: %v", err)
|
||||
}
|
||||
|
||||
if err := app.NewSession(); err != nil {
|
||||
t.Fatalf("NewSession should keep the fresh runtime even when topic repair fails: %v", err)
|
||||
}
|
||||
if got := tab.TopicID; got == "" || got == "topic_old" {
|
||||
t.Fatalf("new session topic ID = %q, want fresh ID distinct from the old topic", got)
|
||||
}
|
||||
if got := tab.TopicTitle; got != defaultTopicTitle {
|
||||
t.Fatalf("new session topic title = %q, want %q", got, defaultTopicTitle)
|
||||
}
|
||||
if got := ctrl.SessionPath(); got == "" || filepath.Clean(got) == filepath.Clean(path) {
|
||||
t.Fatalf("new session path = %q, want a fresh path distinct from %q", got, path)
|
||||
}
|
||||
}
|
||||
@@ -1,262 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAppIconPNGUsesBlueFullCanvasRoundedBackground(t *testing.T) {
|
||||
f, err := os.Open("build/appicon.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
img, err := png.Decode(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
assertFullCanvasRoundedIcon(t, img, 1024)
|
||||
}
|
||||
|
||||
func TestWindowsICOUsesBlueFullCanvasRoundedBackground(t *testing.T) {
|
||||
for _, size := range []int{16, 24, 32, 48, 64, 256} {
|
||||
t.Run(fmt.Sprintf("%dx%d", size, size), func(t *testing.T) {
|
||||
img := decodeICOImage(t, "build/windows/icon.ico", size)
|
||||
assertFullCanvasRoundedIcon(t, img, size)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinICNSUsesMacOSIconSafeArea(t *testing.T) {
|
||||
img := decodeICNSImage(t, "build/darwin/icon.icns", "ic10")
|
||||
if got, want := img.Bounds(), image.Rect(0, 0, 1024, 1024); got != want {
|
||||
t.Fatalf("macOS ic10 frame bounds = %v, want %v", got, want)
|
||||
}
|
||||
if got, want := alphaBounds(img), image.Rect(100, 100, 924, 924); got != want {
|
||||
t.Fatalf("macOS icon visible bounds = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertFullCanvasRoundedIcon(t *testing.T, img image.Image, size int) {
|
||||
t.Helper()
|
||||
|
||||
bounds := img.Bounds()
|
||||
if bounds.Dx() != size || bounds.Dy() != size {
|
||||
t.Fatalf("app icon must be square, got %dx%d", bounds.Dx(), bounds.Dy())
|
||||
}
|
||||
|
||||
corners := []struct {
|
||||
name string
|
||||
x int
|
||||
y int
|
||||
}{
|
||||
{"top-left", bounds.Min.X, bounds.Min.Y},
|
||||
{"top-right", bounds.Max.X - 1, bounds.Min.Y},
|
||||
{"bottom-left", bounds.Min.X, bounds.Max.Y - 1},
|
||||
{"bottom-right", bounds.Max.X - 1, bounds.Max.Y - 1},
|
||||
}
|
||||
for _, corner := range corners {
|
||||
_, _, _, a := img.At(corner.x, corner.y).RGBA()
|
||||
if a > 0xff {
|
||||
t.Fatalf("%s corner must be transparent, alpha=%d", corner.name, a)
|
||||
}
|
||||
}
|
||||
|
||||
_, _, _, centerAlpha := img.At(bounds.Min.X+bounds.Dx()/2, bounds.Min.Y+bounds.Dy()/2).RGBA()
|
||||
if centerAlpha == 0 {
|
||||
t.Fatal("app icon center must contain visible artwork")
|
||||
}
|
||||
|
||||
edgePoints := []struct {
|
||||
name string
|
||||
x int
|
||||
y int
|
||||
}{
|
||||
{"top", bounds.Min.X + bounds.Dx()/2, bounds.Min.Y},
|
||||
{"right", bounds.Max.X - 1, bounds.Min.Y + bounds.Dy()/2},
|
||||
{"bottom", bounds.Min.X + bounds.Dx()/2, bounds.Max.Y - 1},
|
||||
{"left", bounds.Min.X, bounds.Min.Y + bounds.Dy()/2},
|
||||
}
|
||||
for _, point := range edgePoints {
|
||||
_, _, _, a := img.At(point.x, point.y).RGBA()
|
||||
if a == 0 {
|
||||
t.Fatalf("%s edge must contain visible rounded-rect background", point.name)
|
||||
}
|
||||
assertReasonixBlue(t, point.name, img.At(point.x, point.y))
|
||||
}
|
||||
}
|
||||
|
||||
func assertReasonixBlue(t *testing.T, name string, colorValue color.Color) {
|
||||
t.Helper()
|
||||
|
||||
r16, g16, b16, _ := colorValue.RGBA()
|
||||
r, g, b := uint8(r16>>8), uint8(g16>>8), uint8(b16>>8)
|
||||
if !near(r, 0x01, 2) || !near(g, 0x53, 2) || !near(b, 0xe5, 2) {
|
||||
t.Fatalf("%s edge must use Reasonix blue background, got #%02x%02x%02x", name, r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
func near(got, want uint8, tolerance uint8) bool {
|
||||
if got > want {
|
||||
return got-want <= tolerance
|
||||
}
|
||||
return want-got <= tolerance
|
||||
}
|
||||
|
||||
func alphaBounds(img image.Image) image.Rectangle {
|
||||
bounds := img.Bounds()
|
||||
visible := image.Rectangle{}
|
||||
found := false
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
_, _, _, a := img.At(x, y).RGBA()
|
||||
if a == 0 {
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
visible = image.Rect(x, y, x+1, y+1)
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
if x < visible.Min.X {
|
||||
visible.Min.X = x
|
||||
}
|
||||
if y < visible.Min.Y {
|
||||
visible.Min.Y = y
|
||||
}
|
||||
if x >= visible.Max.X {
|
||||
visible.Max.X = x + 1
|
||||
}
|
||||
if y >= visible.Max.Y {
|
||||
visible.Max.Y = y + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func decodeICNSImage(t *testing.T, path, iconType string) image.Image {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(data) < 8 || string(data[:4]) != "icns" {
|
||||
t.Fatal("invalid ICNS header")
|
||||
}
|
||||
if declaredSize := int(binary.BigEndian.Uint32(data[4:8])); declaredSize != len(data) {
|
||||
t.Fatalf("ICNS size = %d, want %d", declaredSize, len(data))
|
||||
}
|
||||
|
||||
for offset := 8; offset < len(data); {
|
||||
if offset+8 > len(data) {
|
||||
t.Fatalf("truncated ICNS entry header at offset %d", offset)
|
||||
}
|
||||
entryType := string(data[offset : offset+4])
|
||||
entrySize := int(binary.BigEndian.Uint32(data[offset+4 : offset+8]))
|
||||
if entrySize < 8 || offset+entrySize > len(data) {
|
||||
t.Fatalf("invalid ICNS entry %q size %d at offset %d", entryType, entrySize, offset)
|
||||
}
|
||||
if entryType == iconType {
|
||||
img, err := png.Decode(bytes.NewReader(data[offset+8 : offset+entrySize]))
|
||||
if err != nil {
|
||||
t.Fatalf("decode ICNS entry %q: %v", iconType, err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
offset += entrySize
|
||||
}
|
||||
|
||||
t.Fatalf("ICNS is missing %q image", iconType)
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeICOImage(t *testing.T, path string, size int) image.Image {
|
||||
t.Helper()
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r := bytes.NewReader(data)
|
||||
|
||||
var header struct {
|
||||
Reserved uint16
|
||||
Type uint16
|
||||
Count uint16
|
||||
}
|
||||
if err := binary.Read(r, binary.LittleEndian, &header); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if header.Reserved != 0 || header.Type != 1 {
|
||||
t.Fatalf("invalid ICO header: reserved=%d type=%d", header.Reserved, header.Type)
|
||||
}
|
||||
|
||||
type iconEntry struct {
|
||||
Width uint8
|
||||
Height uint8
|
||||
ColorCount uint8
|
||||
Reserved uint8
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
BytesInRes uint32
|
||||
ImageOffset uint32
|
||||
}
|
||||
|
||||
entries := make([]iconEntry, header.Count)
|
||||
for i := range entries {
|
||||
if err := binary.Read(r, binary.LittleEndian, &entries[i]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
expectedSizes := map[int]bool{16: false, 24: false, 32: false, 48: false, 64: false, 256: false}
|
||||
targetIndex := -1
|
||||
for i, entry := range entries {
|
||||
width := int(entry.Width)
|
||||
height := int(entry.Height)
|
||||
if width == 0 {
|
||||
width = 256
|
||||
}
|
||||
if height == 0 {
|
||||
height = 256
|
||||
}
|
||||
if width != height {
|
||||
t.Fatalf("ICO image must be square, got %dx%d", width, height)
|
||||
}
|
||||
if _, ok := expectedSizes[width]; ok {
|
||||
expectedSizes[width] = true
|
||||
}
|
||||
if width == size {
|
||||
targetIndex = i
|
||||
}
|
||||
}
|
||||
for expectedSize, found := range expectedSizes {
|
||||
if !found {
|
||||
t.Fatalf("ICO is missing %dx%d image", expectedSize, expectedSize)
|
||||
}
|
||||
}
|
||||
if targetIndex < 0 {
|
||||
t.Fatalf("ICO is missing %dx%d image", size, size)
|
||||
}
|
||||
|
||||
entry := entries[targetIndex]
|
||||
end := int(entry.ImageOffset + entry.BytesInRes)
|
||||
if end > len(data) {
|
||||
t.Fatalf("ICO image offset exceeds file size: offset=%d size=%d file=%d", entry.ImageOffset, entry.BytesInRes, len(data))
|
||||
}
|
||||
img, err := png.Decode(bytes.NewReader(data[entry.ImageOffset:end]))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return img
|
||||
}
|
||||
@@ -1,427 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const desktopTinyPNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
|
||||
func TestWorkspaceRelativeIn(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
if rel, ok := workspaceRelativeIn(filepath.Join(root, "sub", "file.go"), root); !ok || rel != "sub/file.go" {
|
||||
t.Fatalf("in-tree = (%q, %v), want (sub/file.go, true)", rel, ok)
|
||||
}
|
||||
if _, ok := workspaceRelativeIn(filepath.Join(filepath.Dir(root), "sibling.txt"), root); ok {
|
||||
t.Fatal("a path above the workspace must not resolve as in-tree")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsImageExt(t *testing.T) {
|
||||
for _, p := range []string{"a.png", "A.PNG", "b.jpeg", "c.webp"} {
|
||||
if !isImageExt(p) {
|
||||
t.Errorf("%q should be an image extension", p)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{"notes.pdf", "main.go", "noext"} {
|
||||
if isImageExt(p) {
|
||||
t.Errorf("%q should not be an image extension", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePastedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(projectRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectRoot, _ = os.Getwd()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePastedImage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got))); err != nil {
|
||||
t.Fatalf("pasted image should be saved under active workspace: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
||||
t.Fatalf("pasted image should not be saved under launch root, stat err=%v", err)
|
||||
}
|
||||
preview, err := app.AttachmentDataURL(got)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachmentDataURL: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(preview, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", preview)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePastedImageUsesPinnedSessionOwnerBeforeStaleWorkspaceRoot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_attach_owner", "Attach owner", projectA, "project A prompt", time.Now())
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectB, SessionPath: sessionPathA},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.SavePastedImage("data:image/png;base64," + desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatalf("SavePastedImage: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectA, filepath.FromSlash(got))); err != nil {
|
||||
t.Fatalf("pasted image should be saved under pinned session owner project A: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectB, filepath.FromSlash(got))); !os.IsNotExist(err) {
|
||||
t.Fatalf("pasted image should not be saved under stale project B, stat err=%v", err)
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(app.tabs["project"].WorkspaceRoot); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("tab workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(projectRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectRoot, _ = os.Getwd()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(projectRoot, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(projectRoot, "sub", "notes.txt")
|
||||
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(target)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
||||
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedImageUsesActiveWorkspaceRoot(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
launchRoot := t.TempDir()
|
||||
projectRoot := t.TempDir()
|
||||
if err := os.Chdir(launchRoot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: projectRoot},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(desktopTinyPNG)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outside := filepath.Join(t.TempDir(), "shot.png")
|
||||
if err := os.WriteFile(outside, raw, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
||||
t.Fatalf("got %+v, want png attachment", got)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(projectRoot, filepath.FromSlash(got.Path))); err != nil {
|
||||
t.Fatalf("dropped image should be saved under active workspace: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(launchRoot, filepath.FromSlash(got.Path))); !os.IsNotExist(err) {
|
||||
t.Fatalf("dropped image should not be saved under launch root, stat err=%v", err)
|
||||
}
|
||||
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedInWorkspaceReferencesInPlace(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
if err := os.MkdirAll(filepath.Join(cwd, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(cwd, "sub", "notes.txt")
|
||||
if err := os.WriteFile(target, []byte("body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(target)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || got.Path != "sub/notes.txt" {
|
||||
t.Fatalf("got %+v, want workspace ref sub/notes.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceCopiesToAttachments(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
outside := filepath.Join(t.TempDir(), "report.pdf")
|
||||
if err := os.WriteFile(outside, []byte("%PDF body"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasPrefix(got.Path, ".reasonix/attachments/") || !strings.HasSuffix(got.Path, ".pdf") {
|
||||
t.Fatalf("got %+v, want copied pdf attachment", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedImageStoresThumbnail(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cwd, _ := os.Getwd()
|
||||
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 64)...)
|
||||
if err := os.WriteFile(filepath.Join(cwd, "shot.png"), png, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := (&App{}).AttachDropped(filepath.Join(cwd, "shot.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "attachment" || !strings.HasSuffix(got.Path, ".png") {
|
||||
t.Fatalf("got %+v, want png attachment", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.PreviewURL, "data:image/png;base64,") {
|
||||
t.Fatalf("preview = %q, want png data URL", got.PreviewURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersWorkspaceRef(t *testing.T) {
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
workspace := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "Folder With Spaces")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
expectedOutside := outside
|
||||
if resolved, err := filepath.EvalSymlinks(outside); err == nil {
|
||||
expectedOutside = resolved
|
||||
}
|
||||
expectedDisplayPath := filepath.ToSlash(expectedOutside)
|
||||
if err := os.Chdir(workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctrl := control.New(control.Options{WorkspaceRoot: workspace})
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", WorkspaceRoot: workspace, Ctrl: ctrl},
|
||||
},
|
||||
activeTabID: "project",
|
||||
}
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if got.Kind != "workspace" || !got.IsDir {
|
||||
t.Fatalf("got %+v, want workspace directory ref", got)
|
||||
}
|
||||
if !strings.HasPrefix(got.Path, "__reasonix_external_folder/") || strings.ContainsAny(got.Path, " \t\r\n") {
|
||||
t.Fatalf("external folder path token = %q, want whitespace-free external token", got.Path)
|
||||
}
|
||||
if got.DisplayPath != expectedDisplayPath {
|
||||
t.Fatalf("display path = %q, want %q", got.DisplayPath, expectedDisplayPath)
|
||||
}
|
||||
|
||||
block, errs := ctrl.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, `<dir path="`+expectedDisplayPath+`">`) ||
|
||||
!strings.Contains(block, "sub/") ||
|
||||
!strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve as dir context:\n%s", block)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttachDroppedOutsideWorkspaceDirRegistersAfterPinnedOwnerRebuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
setDesktopTestCredential(t, "TEST_MODEL_KEY", "sk-test")
|
||||
cfg := config.Default()
|
||||
cfg.DefaultModel = "test/test-model"
|
||||
cfg.Desktop.ProviderAccess = []string{"test"}
|
||||
cfg.Providers = []config.ProviderEntry{
|
||||
{Name: "test", Kind: "openai", BaseURL: "https://example.invalid/v1", Model: "test-model", APIKeyEnv: "TEST_MODEL_KEY"},
|
||||
}
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save config: %v", err)
|
||||
}
|
||||
orig, _ := os.Getwd()
|
||||
defer os.Chdir(orig)
|
||||
|
||||
projectA := t.TempDir()
|
||||
projectB := t.TempDir()
|
||||
outside := filepath.Join(t.TempDir(), "External")
|
||||
if err := os.MkdirAll(filepath.Join(outside, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(outside, "sub", "notes.txt"), []byte("notes"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := addProject(projectA, "Project A"); err != nil {
|
||||
t.Fatalf("add project A: %v", err)
|
||||
}
|
||||
if err := addProject(projectB, "Project B"); err != nil {
|
||||
t.Fatalf("add project B: %v", err)
|
||||
}
|
||||
sessionDirA := desktopSessionDir(projectA)
|
||||
sessionDirB := desktopSessionDir(projectB)
|
||||
if err := os.MkdirAll(sessionDirA, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project A sessions: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(sessionDirB, 0o755); err != nil {
|
||||
t.Fatalf("mkdir project B sessions: %v", err)
|
||||
}
|
||||
sessionPathA := writeTopicSessionWithPrompt(t, sessionDirA, "project-a.jsonl", "topic_external_ref", "External ref", projectA, "project A prompt", time.Now())
|
||||
sessionPathB := filepath.Join(sessionDirB, "wrong.jsonl")
|
||||
oldCtrl := control.New(control.Options{
|
||||
SessionDir: sessionDirB,
|
||||
SessionPath: sessionPathB,
|
||||
WorkspaceRoot: projectB,
|
||||
Sink: event.Discard,
|
||||
})
|
||||
app := NewApp()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "project",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: projectB,
|
||||
TopicID: "topic_external_ref",
|
||||
TopicTitle: "External ref",
|
||||
SessionPath: sessionPathA,
|
||||
Ready: true,
|
||||
model: "test/test-model",
|
||||
Ctrl: oldCtrl,
|
||||
sink: &tabEventSink{tabID: "project", app: app},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
t.Cleanup(func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
})
|
||||
|
||||
got, err := app.AttachDropped(outside)
|
||||
if err != nil {
|
||||
t.Fatalf("AttachDropped: %v", err)
|
||||
}
|
||||
if tab.Ctrl == oldCtrl {
|
||||
t.Fatal("stale controller was reused for external folder ref")
|
||||
}
|
||||
if gotRoot := normalizeProjectRoot(tab.Ctrl.WorkspaceRoot()); gotRoot != normalizeProjectRoot(projectA) {
|
||||
t.Fatalf("controller workspace root = %q, want project A %q", gotRoot, normalizeProjectRoot(projectA))
|
||||
}
|
||||
resolver, ok := tab.Ctrl.(interface {
|
||||
ResolveScopedRefs(context.Context, string) (string, []string)
|
||||
})
|
||||
if !ok {
|
||||
t.Fatalf("rebuilt controller does not resolve scoped refs: %T", tab.Ctrl)
|
||||
}
|
||||
block, errs := resolver.ResolveScopedRefs(context.Background(), "inspect @"+got.Path+"/")
|
||||
if len(errs) != 0 {
|
||||
t.Fatalf("ResolveScopedRefs errors = %v", errs)
|
||||
}
|
||||
if !strings.Contains(block, "sub/notes.txt") {
|
||||
t.Fatalf("external dropped folder should resolve on rebuilt controller:\n%s", block)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch exercises the
|
||||
// desktop wiring after boot.Build. Retired global/project keys must not disable
|
||||
// Auto Guard either during controller construction or fresh-session binding.
|
||||
func TestBuildTabControllerIgnoresRetiredAutoRecoveryKillSwitch(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
userCfg := config.UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(userCfg), 0o755); err != nil {
|
||||
t.Fatalf("mkdir user config: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(userCfg, []byte(`
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
auto_recovery_checkpoint = "on"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write user config: %v", err)
|
||||
}
|
||||
|
||||
root := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte(`
|
||||
default_model = "test-model"
|
||||
|
||||
[agent]
|
||||
auto_recovery_checkpoint = "off"
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = "openai"
|
||||
base_url = "https://example.invalid"
|
||||
model = "x"
|
||||
api_key_env = "REASONIX_TEST_KEY_UNSET"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
tab := &WorkspaceTab{
|
||||
ID: "project-auto-guard-off",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: root,
|
||||
model: "test-model",
|
||||
tokenMode: boot.TokenModeFull,
|
||||
mode: "normal",
|
||||
toolApprovalMode: control.ToolApprovalAuto,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: app}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
defer func() {
|
||||
if tab.Ctrl != nil {
|
||||
tab.Ctrl.Close()
|
||||
}
|
||||
app.releaseTabSharedHost(tab)
|
||||
tab.releaseSessionLease()
|
||||
}()
|
||||
|
||||
app.buildTabController(tab)
|
||||
if tab.StartupErr != "" {
|
||||
t.Fatalf("build tab controller: %s", tab.StartupErr)
|
||||
}
|
||||
ctrl, ok := tab.Ctrl.(*control.Controller)
|
||||
if !ok || ctrl == nil {
|
||||
t.Fatalf("tab controller = %T, want *control.Controller", tab.Ctrl)
|
||||
}
|
||||
|
||||
fresh := filepath.Join(ctrl.SessionDir(), "fresh-auto-guard-off.jsonl")
|
||||
ctrl.SetFreshSessionPath(fresh)
|
||||
if got := ctrl.SessionPath(); got != fresh {
|
||||
t.Fatalf("fresh session path = %q, want %q", got, fresh)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestReportTabSnapshotErrorDebouncesAutosave(t *testing.T) {
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{ID: "tab_warn", sink: &tabEventSink{tabID: "tab_warn", app: app}}
|
||||
failure := errors.New("disk unhappy")
|
||||
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
first := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if first.IsZero() {
|
||||
t.Fatal("first autosave warning did not record its timestamp")
|
||||
}
|
||||
|
||||
// Within the window: suppressed, timestamp untouched.
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
second := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !second.Equal(first) {
|
||||
t.Fatalf("suppressed warning moved the debounce timestamp: %v -> %v", first, second)
|
||||
}
|
||||
|
||||
// Window expired: warns again.
|
||||
aged := time.Now().Add(-autosaveWarnInterval - time.Second)
|
||||
tab.saveMu.Lock()
|
||||
tab.lastAutosaveWarnAt = aged
|
||||
tab.saveMu.Unlock()
|
||||
app.reportTabSnapshotError(tab, "autosave", failure)
|
||||
tab.saveMu.Lock()
|
||||
third := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !third.After(aged) {
|
||||
t.Fatal("expired window did not re-arm the autosave warning")
|
||||
}
|
||||
|
||||
// Explicit user actions are never debounced (state untouched).
|
||||
app.reportTabSnapshotError(tab, "changing model", failure)
|
||||
tab.saveMu.Lock()
|
||||
fourth := tab.lastAutosaveWarnAt
|
||||
tab.saveMu.Unlock()
|
||||
if !fourth.Equal(third) {
|
||||
t.Fatal("action-save warning must not consume the autosave debounce window")
|
||||
}
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/workspacelease"
|
||||
)
|
||||
|
||||
// ActiveWorkView is the structured Desktop contract for work that prevents a
|
||||
// controller rebuild or a destructive tab action. Jobs is always a JSON array.
|
||||
type ActiveWorkView struct {
|
||||
Running bool `json:"running"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
Jobs []JobView `json:"jobs"`
|
||||
}
|
||||
|
||||
// JobCancelBatchView reports which requested jobs accepted cancellation. Both
|
||||
// slices are initialized so Wails never sends null to React.
|
||||
type JobCancelBatchView struct {
|
||||
Cancelled []string `json:"cancelled"`
|
||||
NotRunning []string `json:"notRunning"`
|
||||
}
|
||||
|
||||
// BackgroundRuntimeView is one visible or detached runtime with active work.
|
||||
// TabID is an opaque process-local handle; paths and session writer ids are
|
||||
// deliberately omitted from this user-facing contract.
|
||||
type BackgroundRuntimeView struct {
|
||||
TabID string `json:"tabId"`
|
||||
Title string `json:"title"`
|
||||
Detached bool `json:"detached"`
|
||||
Running bool `json:"running"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
Jobs []JobView `json:"jobs"`
|
||||
}
|
||||
|
||||
// WorkspaceConflictView describes a currently-waiting Delivery writer without
|
||||
// exposing the lock path, process id, or session path.
|
||||
type WorkspaceConflictView struct {
|
||||
State string `json:"state"`
|
||||
OwnerTabID string `json:"ownerTabId,omitempty"`
|
||||
OwnerTitle string `json:"ownerTitle,omitempty"`
|
||||
OwnerWork ActiveWorkView `json:"ownerWork"`
|
||||
CanReveal bool `json:"canReveal"`
|
||||
CanCreateWorktree bool `json:"canCreateWorktree"`
|
||||
}
|
||||
|
||||
func activeWorkForController(ctrl control.SessionAPI) ActiveWorkView {
|
||||
view := ActiveWorkView{Jobs: []JobView{}}
|
||||
if ctrl == nil {
|
||||
return view
|
||||
}
|
||||
status := ctrl.RuntimeStatus()
|
||||
view.Running = status.Running
|
||||
view.PendingPrompt = status.PendingPrompt
|
||||
view.Cancellable = status.Cancellable
|
||||
for _, job := range ctrl.Jobs() {
|
||||
view.Jobs = append(view.Jobs, JobView{
|
||||
ID: job.ID, Kind: job.Kind, Label: job.Label,
|
||||
Status: job.Status, StartedAt: job.StartedAt,
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func (v ActiveWorkView) active() bool {
|
||||
return v.Running || v.PendingPrompt || len(v.Jobs) > 0
|
||||
}
|
||||
|
||||
// ActiveWorkForTab returns the precise blocker state for one tab. It is a
|
||||
// preflight aid only; rebuild paths still re-check active work atomically.
|
||||
func (a *App) ActiveWorkForTab(tabID string) ActiveWorkView {
|
||||
return activeWorkForController(a.ctrlForRuntimeTabID(tabID))
|
||||
}
|
||||
|
||||
// CancelJobsForTab requests cancellation for a stable tab id. The job manager
|
||||
// keeps cancelled-but-unwinding jobs visible until their done channels close.
|
||||
func (a *App) CancelJobsForTab(tabID string, jobIDs []string) (JobCancelBatchView, error) {
|
||||
result := JobCancelBatchView{Cancelled: []string{}, NotRunning: []string{}}
|
||||
seen := map[string]bool{}
|
||||
for _, raw := range jobIDs {
|
||||
id := strings.TrimSpace(raw)
|
||||
if id == "" || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
cancelled, err := a.CancelJobForTab(tabID, id)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if cancelled {
|
||||
result.Cancelled = append(result.Cancelled, id)
|
||||
} else {
|
||||
result.NotRunning = append(result.NotRunning, id)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type backgroundRuntimeSnapshot struct {
|
||||
id string
|
||||
ctrl control.SessionAPI
|
||||
detached bool
|
||||
title string
|
||||
}
|
||||
|
||||
// BackgroundRuntimes returns every process-local runtime that still needs a
|
||||
// visible recovery path, including runtimes detached by an explicit tab close.
|
||||
func (a *App) BackgroundRuntimes() []BackgroundRuntimeView {
|
||||
a.mu.RLock()
|
||||
snapshots := make([]backgroundRuntimeSnapshot, 0, len(a.tabs)+len(a.detachedSessions))
|
||||
seen := map[*WorkspaceTab]bool{}
|
||||
for _, tab := range a.tabs {
|
||||
if tab == nil || seen[tab] {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, title: title})
|
||||
}
|
||||
for _, tab := range a.detachedSessions {
|
||||
if tab == nil || seen[tab] {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
snapshots = append(snapshots, backgroundRuntimeSnapshot{id: tab.ID, ctrl: tab.Ctrl, detached: true, title: title})
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
out := make([]BackgroundRuntimeView, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
work := activeWorkForController(snapshot.ctrl)
|
||||
if !work.active() {
|
||||
continue
|
||||
}
|
||||
out = append(out, BackgroundRuntimeView{
|
||||
TabID: snapshot.id, Title: snapshot.title, Detached: snapshot.detached,
|
||||
Running: work.Running, PendingPrompt: work.PendingPrompt, Jobs: work.Jobs,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ctrlForRuntimeTabID(tabID string) control.SessionAPI {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if strings.TrimSpace(tabID) == "" {
|
||||
return a.activeCtrlLocked()
|
||||
}
|
||||
tab := a.tabByEventSinkIDLocked(tabID)
|
||||
if tab == nil {
|
||||
return nil
|
||||
}
|
||||
return tab.Ctrl
|
||||
}
|
||||
|
||||
// RevealBackgroundRuntime activates a visible owner or reopens the exact
|
||||
// detached session. It never reattaches by workspace alone.
|
||||
func (a *App) RevealBackgroundRuntime(tabID string) (TabMeta, error) {
|
||||
a.mu.RLock()
|
||||
if tab := a.tabs[tabID]; tab != nil {
|
||||
a.mu.RUnlock()
|
||||
if err := a.SetActiveTab(tabID); err != nil {
|
||||
return TabMeta{}, err
|
||||
}
|
||||
a.mu.RLock()
|
||||
current := a.tabs[tabID]
|
||||
if current == nil {
|
||||
a.mu.RUnlock()
|
||||
return TabMeta{}, fmt.Errorf("background task is no longer available")
|
||||
}
|
||||
meta := a.tabMeta(current, true)
|
||||
a.mu.RUnlock()
|
||||
return enrichTabMeta(meta), nil
|
||||
}
|
||||
tab := a.tabByEventSinkIDLocked(tabID)
|
||||
if tab == nil || tab.Ctrl == nil {
|
||||
a.mu.RUnlock()
|
||||
return TabMeta{}, fmt.Errorf("background task is no longer available")
|
||||
}
|
||||
scope := tab.Scope
|
||||
workspaceRoot := tab.WorkspaceRoot
|
||||
topicID := tab.TopicID
|
||||
sessionPath := tab.currentSessionPath()
|
||||
a.mu.RUnlock()
|
||||
if strings.TrimSpace(sessionPath) == "" {
|
||||
return TabMeta{}, fmt.Errorf("background task session is unavailable")
|
||||
}
|
||||
return a.OpenTopicSession(scope, workspaceRoot, topicID, sessionPath)
|
||||
}
|
||||
|
||||
type workspaceLeaseReporter interface {
|
||||
WorkspaceLeaseState() workspacelease.State
|
||||
}
|
||||
|
||||
func controllerWorkspaceLeaseState(ctrl control.SessionAPI) workspacelease.State {
|
||||
if reporter, ok := ctrl.(workspaceLeaseReporter); ok {
|
||||
return reporter.WorkspaceLeaseState()
|
||||
}
|
||||
return workspacelease.State{}
|
||||
}
|
||||
|
||||
// WorkspaceConflictForTab classifies the owner that a Delivery writer is
|
||||
// currently waiting for. An acquired process-local owner is actionable; when no
|
||||
// local owner matches, the OS lock is treated as external.
|
||||
func (a *App) WorkspaceConflictForTab(tabID string) WorkspaceConflictView {
|
||||
empty := WorkspaceConflictView{State: "none", OwnerWork: ActiveWorkView{Jobs: []JobView{}}}
|
||||
a.mu.RLock()
|
||||
var target *WorkspaceTab
|
||||
if strings.TrimSpace(tabID) == "" {
|
||||
target = a.activeTabLocked()
|
||||
} else {
|
||||
target = a.tabByEventSinkIDLocked(tabID)
|
||||
}
|
||||
if target == nil {
|
||||
a.mu.RUnlock()
|
||||
return empty
|
||||
}
|
||||
targetCtrl := target.Ctrl
|
||||
targetWorkspaceRoot := target.WorkspaceRoot
|
||||
a.mu.RUnlock()
|
||||
if targetCtrl == nil {
|
||||
return empty
|
||||
}
|
||||
targetState := controllerWorkspaceLeaseState(targetCtrl)
|
||||
if !targetState.Waiting || targetState.Acquired {
|
||||
return empty
|
||||
}
|
||||
targetRoot, err := workspacelease.CanonicalWorkspace(targetWorkspaceRoot)
|
||||
if err != nil {
|
||||
return empty
|
||||
}
|
||||
availability := a.DeliveryWorktreeAvailability(targetWorkspaceRoot)
|
||||
|
||||
a.mu.RLock()
|
||||
type candidate struct {
|
||||
id string
|
||||
ctrl control.SessionAPI
|
||||
root string
|
||||
title string
|
||||
}
|
||||
candidates := make([]candidate, 0, len(a.tabs)+len(a.detachedSessions))
|
||||
seen := map[*WorkspaceTab]bool{}
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab == nil || tab == target || seen[tab] || tab.Ctrl == nil {
|
||||
continue
|
||||
}
|
||||
seen[tab] = true
|
||||
title := strings.TrimSpace(tab.TopicTitle)
|
||||
if title == "" {
|
||||
title = strings.TrimSpace(tab.Label)
|
||||
}
|
||||
candidates = append(candidates, candidate{id: tab.ID, ctrl: tab.Ctrl, root: tab.WorkspaceRoot, title: title})
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
for _, candidate := range candidates {
|
||||
root, err := workspacelease.CanonicalWorkspace(candidate.root)
|
||||
if err != nil || root != targetRoot || !controllerWorkspaceLeaseState(candidate.ctrl).Acquired {
|
||||
continue
|
||||
}
|
||||
return WorkspaceConflictView{
|
||||
State: "local", OwnerTabID: candidate.id, OwnerTitle: candidate.title,
|
||||
OwnerWork: activeWorkForController(candidate.ctrl), CanReveal: true,
|
||||
CanCreateWorktree: availability.Available,
|
||||
}
|
||||
}
|
||||
empty.State = "external"
|
||||
empty.CanCreateWorktree = availability.Available
|
||||
return empty
|
||||
}
|
||||
|
||||
// RevealWorkspaceWriterForTab opens the exact process-local runtime identified
|
||||
// by WorkspaceConflictForTab. External writers remain non-actionable.
|
||||
func (a *App) RevealWorkspaceWriterForTab(tabID string) (TabMeta, error) {
|
||||
conflict := a.WorkspaceConflictForTab(tabID)
|
||||
if conflict.State != "local" || conflict.OwnerTabID == "" {
|
||||
return TabMeta{}, fmt.Errorf("the workspace writer is not available in this Reasonix window")
|
||||
}
|
||||
return a.RevealBackgroundRuntime(conflict.OwnerTabID)
|
||||
}
|
||||
|
||||
const stopAndCloseGrace = 15 * time.Second
|
||||
|
||||
// CloseTabWithPolicy makes the old implicit detach behavior an explicit user
|
||||
// choice. stop_and_close never removes the tab until all owned work is idle.
|
||||
func (a *App) CloseTabWithPolicy(tabID, policy string) error {
|
||||
switch strings.TrimSpace(policy) {
|
||||
case "keep_running":
|
||||
return a.closeTab(tabID, true)
|
||||
case "stop_and_close":
|
||||
ctrl := a.ctrlForRuntimeTabID(tabID)
|
||||
if ctrl == nil {
|
||||
return a.closeTab(tabID, false)
|
||||
}
|
||||
ctrl.Cancel()
|
||||
jobs := ctrl.Jobs()
|
||||
ids := make([]string, 0, len(jobs))
|
||||
for _, job := range jobs {
|
||||
ids = append(ids, job.ID)
|
||||
}
|
||||
if _, err := a.CancelJobsForTab(tabID, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
deadline := time.NewTimer(stopAndCloseGrace)
|
||||
defer deadline.Stop()
|
||||
ticker := time.NewTicker(25 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if !activeWorkForController(ctrl).active() {
|
||||
return a.closeTab(tabID, false)
|
||||
}
|
||||
select {
|
||||
case <-deadline.C:
|
||||
return fmt.Errorf("background work did not stop within %s; the task was kept open", stopAndCloseGrace)
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown close policy %q", policy)
|
||||
}
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/workspacelease"
|
||||
)
|
||||
|
||||
type backgroundRuntimeController struct {
|
||||
control.SessionAPI
|
||||
status control.RuntimeStatus
|
||||
jobs []jobs.View
|
||||
lease workspacelease.State
|
||||
cancelled []string
|
||||
turnCancel int
|
||||
}
|
||||
|
||||
func (c *backgroundRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
|
||||
func (c *backgroundRuntimeController) Jobs() []jobs.View {
|
||||
return append([]jobs.View(nil), c.jobs...)
|
||||
}
|
||||
func (c *backgroundRuntimeController) CancelJob(id string) bool {
|
||||
for _, job := range c.jobs {
|
||||
if job.ID == id {
|
||||
c.cancelled = append(c.cancelled, id)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func (c *backgroundRuntimeController) Cancel() { c.turnCancel++ }
|
||||
func (c *backgroundRuntimeController) WorkspaceLeaseState() workspacelease.State {
|
||||
return c.lease
|
||||
}
|
||||
|
||||
func TestBackgroundRuntimeAPIsKeepDetachedJobsActionable(t *testing.T) {
|
||||
targetCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-target", Kind: "bash", Label: "target tests", Status: "running", StartedAt: 1}},
|
||||
}
|
||||
focusedCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-focused", Kind: "bash", Label: "focused tests", Status: "running", StartedAt: 2}},
|
||||
}
|
||||
target := &WorkspaceTab{ID: "target", TopicTitle: "Detached task", Ctrl: targetCtrl}
|
||||
focused := &WorkspaceTab{ID: "focused", TopicTitle: "Focused task", Ctrl: focusedCtrl}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"focused": focused},
|
||||
tabOrder: []string{"focused"},
|
||||
activeTabID: "focused",
|
||||
detachedSessions: map[string]*WorkspaceTab{"session-target": target},
|
||||
}
|
||||
|
||||
work := app.ActiveWorkForTab("target")
|
||||
if len(work.Jobs) != 1 || work.Jobs[0].ID != "job-target" {
|
||||
t.Fatalf("ActiveWorkForTab(target) = %+v, want target job", work)
|
||||
}
|
||||
result, err := app.CancelJobsForTab("target", []string{"job-target", "job-target", "missing"})
|
||||
if err != nil {
|
||||
t.Fatalf("CancelJobsForTab: %v", err)
|
||||
}
|
||||
if len(result.Cancelled) != 1 || result.Cancelled[0] != "job-target" || len(result.NotRunning) != 1 || result.NotRunning[0] != "missing" {
|
||||
t.Fatalf("CancelJobsForTab result = %+v", result)
|
||||
}
|
||||
if len(targetCtrl.cancelled) != 1 || len(focusedCtrl.cancelled) != 0 {
|
||||
t.Fatalf("cancellation routing target=%v focused=%v", targetCtrl.cancelled, focusedCtrl.cancelled)
|
||||
}
|
||||
|
||||
runtimes := app.BackgroundRuntimes()
|
||||
if len(runtimes) != 2 {
|
||||
t.Fatalf("BackgroundRuntimes = %+v, want visible and detached", runtimes)
|
||||
}
|
||||
var foundDetached bool
|
||||
for _, runtime := range runtimes {
|
||||
if runtime.TabID == "target" {
|
||||
foundDetached = runtime.Detached && runtime.Title == "Detached task" && len(runtime.Jobs) == 1
|
||||
}
|
||||
}
|
||||
if !foundDetached {
|
||||
t.Fatalf("detached runtime missing from %+v", runtimes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelJobForLocalRuntimeByExplicitTab(t *testing.T) {
|
||||
localCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "job-local", Kind: "bash", Status: "running"}},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{"local": {ID: "local", Ctrl: localCtrl}},
|
||||
tabOrder: []string{"local"},
|
||||
activeTabID: "local",
|
||||
}
|
||||
|
||||
listed := app.JobsForTab("local")
|
||||
if len(listed) != 1 || listed[0].ID != "job-local" {
|
||||
t.Fatalf("JobsForTab(local) = %+v, want local job", listed)
|
||||
}
|
||||
cancelled, err := app.CancelJobForTab("local", "job-local")
|
||||
if err != nil {
|
||||
t.Fatalf("CancelJobForTab(local): %v", err)
|
||||
}
|
||||
if !cancelled || len(localCtrl.cancelled) != 1 || localCtrl.cancelled[0] != "job-local" {
|
||||
t.Fatalf("local cancellation = %t, calls=%v; want local job cancelled", cancelled, localCtrl.cancelled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkspaceConflictIdentifiesExactLocalWriterWithoutIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
waiterCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{Running: true},
|
||||
lease: workspacelease.State{Waiting: true},
|
||||
}
|
||||
ownerCtrl := &backgroundRuntimeController{
|
||||
status: control.RuntimeStatus{BackgroundJobs: 1},
|
||||
jobs: []jobs.View{{ID: "owner-job", Kind: "bash", Status: "running"}},
|
||||
lease: workspacelease.State{Acquired: true},
|
||||
}
|
||||
app := &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"waiter": {ID: "waiter", WorkspaceRoot: root, Ctrl: waiterCtrl},
|
||||
"owner": {ID: "owner", TopicTitle: "Writing task", WorkspaceRoot: root, Ctrl: ownerCtrl},
|
||||
},
|
||||
tabOrder: []string{"waiter", "owner"},
|
||||
activeTabID: "waiter",
|
||||
}
|
||||
|
||||
conflict := app.WorkspaceConflictForTab("waiter")
|
||||
if conflict.State != "local" || conflict.OwnerTabID != "owner" || conflict.OwnerTitle != "Writing task" || !conflict.CanReveal {
|
||||
t.Fatalf("WorkspaceConflictForTab = %+v, want exact local owner", conflict)
|
||||
}
|
||||
if len(conflict.OwnerWork.Jobs) != 1 || conflict.OwnerWork.Jobs[0].ID != "owner-job" {
|
||||
t.Fatalf("owner work = %+v, want sanitized active work", conflict.OwnerWork)
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/billing"
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
type billingRuntimeController struct {
|
||||
control.SessionAPI
|
||||
status control.RuntimeStatus
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
balance *billing.Balance
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *billingRuntimeController) RuntimeStatus() control.RuntimeStatus { return c.status }
|
||||
func (c *billingRuntimeController) SessionPath() string { return "" }
|
||||
|
||||
func (c *billingRuntimeController) Balance(ctx context.Context) (*billing.Balance, error) {
|
||||
if c.started != nil {
|
||||
close(c.started)
|
||||
}
|
||||
if c.release != nil {
|
||||
select {
|
||||
case <-c.release:
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
return c.balance, c.err
|
||||
}
|
||||
|
||||
func TestSetDesktopCurrencyRebindsLedgerWithoutControllerRebuild(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &billingRuntimeController{status: control.RuntimeStatus{Running: true}}
|
||||
ledger := billing.NewLedger()
|
||||
ledger.Add(billing.CostQuote{
|
||||
Original: billing.Money{Amount: "1", Currency: "USD"},
|
||||
Valuations: map[string]billing.Valuation{
|
||||
"USD": {Money: billing.Money{Amount: "1", Currency: "USD"}, Basis: billing.BasisIdentity},
|
||||
"CNY": {Money: billing.Money{Amount: "7", Currency: "CNY"}, Basis: billing.BasisOfficialTable},
|
||||
},
|
||||
Selected: &billing.Money{Amount: "1", Currency: "USD"},
|
||||
Estimated: true,
|
||||
CostComplete: true,
|
||||
DisplayComplete: true,
|
||||
Complete: true,
|
||||
DisplayStatus: billing.DisplayStatusMatched,
|
||||
ModelRef: "deepseek-flash/deepseek-v4-flash",
|
||||
}, billing.UsageTokens{PromptTokens: 1_000_000}, nowUTC())
|
||||
tab := &WorkspaceTab{
|
||||
ID: "billing", Ctrl: ctrl,
|
||||
usageTelemetry: sessionUsageStats{CostLedger: ledger, SessionCost: 1, SessionCurrency: "USD", SessionCostComplete: true},
|
||||
}
|
||||
app := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
|
||||
|
||||
if err := app.SetDesktopCurrency("CNY"); err != nil {
|
||||
t.Fatalf("SetDesktopCurrency during active work: %v", err)
|
||||
}
|
||||
if tab.Ctrl != ctrl {
|
||||
t.Fatal("display-only currency change replaced the controller")
|
||||
}
|
||||
quote := tab.telemetrySnapshot().Usage.SessionCostQuote
|
||||
if quote == nil || quote.Selected == nil || quote.Selected.Currency != "CNY" || quote.Selected.Amount != "7" {
|
||||
t.Fatalf("rebound quote = %+v, want selected CNY 7", quote)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceForTabRejectsPreviousSessionResponse(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &billingRuntimeController{
|
||||
started: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
|
||||
}
|
||||
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
|
||||
tab.resetTelemetry("old-session.jsonl")
|
||||
app := &App{
|
||||
ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab},
|
||||
tabOrder: []string{tab.ID}, activeTabID: tab.ID,
|
||||
}
|
||||
|
||||
done := make(chan BalanceInfo, 1)
|
||||
go func() { done <- app.BalanceForTab(tab.ID) }()
|
||||
<-ctrl.started
|
||||
tab.resetTelemetry("new-session.jsonl")
|
||||
close(ctrl.release)
|
||||
<-done
|
||||
|
||||
tab.telemMu.Lock()
|
||||
display := tab.runtimeCostDisplayCurrency
|
||||
tab.telemMu.Unlock()
|
||||
if display != "" {
|
||||
t.Fatalf("previous-session balance rebound new session to %q", display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceForTabRejectsPreviousControllerResponse(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &billingRuntimeController{
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
|
||||
}
|
||||
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
|
||||
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
|
||||
|
||||
done := make(chan BalanceInfo, 1)
|
||||
go func() { done <- app.BalanceForTab(tab.ID) }()
|
||||
<-ctrl.started
|
||||
app.mu.Lock()
|
||||
tab.Ctrl = &billingRuntimeController{}
|
||||
app.mu.Unlock()
|
||||
close(ctrl.release)
|
||||
<-done
|
||||
|
||||
tab.telemMu.Lock()
|
||||
display := tab.runtimeCostDisplayCurrency
|
||||
tab.telemMu.Unlock()
|
||||
if display != "" {
|
||||
t.Fatalf("previous-controller balance rebound current controller to %q", display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceForTabRejectsResponseAfterExplicitCurrencyChange(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &billingRuntimeController{
|
||||
started: make(chan struct{}), release: make(chan struct{}),
|
||||
balance: &billing.Balance{Available: true, Infos: []billing.Info{{Currency: "CNY", TotalBalance: "70.16"}}},
|
||||
}
|
||||
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
|
||||
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
|
||||
|
||||
done := make(chan BalanceInfo, 1)
|
||||
go func() { done <- app.BalanceForTab(tab.ID) }()
|
||||
<-ctrl.started
|
||||
if err := app.SetDesktopCurrency("USD"); err != nil {
|
||||
t.Fatalf("SetDesktopCurrency: %v", err)
|
||||
}
|
||||
close(ctrl.release)
|
||||
<-done
|
||||
|
||||
tab.telemMu.Lock()
|
||||
display := tab.runtimeCostDisplayCurrency
|
||||
tab.telemMu.Unlock()
|
||||
if display != "" {
|
||||
t.Fatalf("auto balance response overrode explicit USD with %q", display)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBalanceForTabFailureDoesNotChangeDisplayBinding(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
ctrl := &billingRuntimeController{err: errors.New("balance unavailable")}
|
||||
tab := &WorkspaceTab{ID: "billing", Ctrl: ctrl}
|
||||
app := &App{ctx: context.Background(), tabs: map[string]*WorkspaceTab{tab.ID: tab}, tabOrder: []string{tab.ID}, activeTabID: tab.ID}
|
||||
|
||||
got := app.BalanceForTab(tab.ID)
|
||||
if got.Err == "" {
|
||||
t.Fatal("BalanceForTab error was not reported")
|
||||
}
|
||||
tab.telemMu.Lock()
|
||||
display := tab.runtimeCostDisplayCurrency
|
||||
tab.telemMu.Unlock()
|
||||
if display != "" {
|
||||
t.Fatalf("failed balance rebound display to %q", display)
|
||||
}
|
||||
}
|
||||
|
||||
func nowUTC() time.Time { return time.Now().UTC() }
|
||||
@@ -1,713 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// errDriveBusy signals that a takeover drive could not start because the target
|
||||
// controller was already running a turn. The hub translates it into a
|
||||
// user-facing "session busy" message rather than a generic drive failure.
|
||||
var errDriveBusy = errors.New("desktop session busy")
|
||||
|
||||
// botBridgeHub 是 bot 网关对桌面端的"上帝视角"桥(bot.DesktopBridge 的实现)。
|
||||
//
|
||||
// 职责边界(刻意保持窄):
|
||||
// - 观察:tabEventSink.Emit 把每个桌面会话的事件旁路到 observe;hub 记录
|
||||
// 待审批/待回答项,并把审批请求、任务完成/出错推送给订阅聊天。
|
||||
// - 遥控审批:/desktop approve|deny|answer 经 App.ApproveTab /
|
||||
// AnswerQuestionForTab 按 tab 寻址回写。controller 侧幂等(先到者赢),
|
||||
// 桌面 UI 与远端并发应答互不干扰。
|
||||
// - 不做:向桌面会话注入输入、抢占写 lease。将来的显式接管在
|
||||
// bot.DesktopBridge 上扩展,底层走 internal/control 的接管端口。
|
||||
//
|
||||
// observe 跑在 controller 的事件 goroutine 上,绝不能做网络调用——通知统一
|
||||
// 进有界队列由 worker 异步发送,队列满时丢弃并告警。
|
||||
type botBridgeHub struct {
|
||||
sessions func() []bot.DesktopSessionInfo
|
||||
approveTab func(tabID, id string, allow, session, persist bool)
|
||||
answerTab func(tabID, id string, answers []QuestionAnswer)
|
||||
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
|
||||
// drive 把一条远程文本提交为 tab 的新 turn,并把该 turn 的输出转发回 route。
|
||||
drive func(tabID, text string, route bot.DesktopWatchRoute) error
|
||||
// announce 往会话 transcript 里发一条 Notice,让桌面用户看到接管状态变化。
|
||||
announce func(tabID, text string)
|
||||
// persistWatchers 把订阅全集回写用户配置(bot.desktop_watchers)。
|
||||
persistWatchers func(routes []bot.DesktopWatchRoute) error
|
||||
// takeoverChanged 通知桌面前端刷新(TabMeta.RemoteControlled 变化)。
|
||||
takeoverChanged func()
|
||||
logger *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
watchers map[string]bot.DesktopWatchRoute
|
||||
pending map[string]desktopPendingPrompt
|
||||
// takeovers: tabID -> 驾驶该会话的聊天路由;takeoverTabs: routeKey -> tabID。
|
||||
takeovers map[string]bot.DesktopWatchRoute
|
||||
takeoverTabs map[string]string
|
||||
// watchSeq 单调递增,标记订阅快照的新旧;persist 时用它丢弃过期写入。
|
||||
watchSeq uint64
|
||||
// watchPersistDirty keeps a failed local mutation authoritative in memory;
|
||||
// a later runtime refresh must not silently restore the older disk snapshot.
|
||||
watchPersistDirty bool
|
||||
|
||||
// persistMu 串行化订阅落盘,并保证只写最新快照(见 SetWatch)。
|
||||
persistMu sync.Mutex
|
||||
lastPersistSeq uint64
|
||||
|
||||
queue chan desktopBridgeNotification
|
||||
}
|
||||
|
||||
type desktopPendingPrompt struct {
|
||||
tabID string
|
||||
kind string // "approval" | "ask"
|
||||
tool string
|
||||
subject string
|
||||
questions []event.AskQuestion
|
||||
}
|
||||
|
||||
// desktopBridgeNotification 是一条待推送的桌面事件。text 与 card 都按订阅路由
|
||||
// 现做,因此群聊(多用户)与私聊可给出不同详略——命令行/错误详情只发私聊,群里
|
||||
// 只给摘要。route 非 nil 时定向发给该聊天(不看 watch 订阅),用于接管收回等必达通知。
|
||||
type desktopBridgeNotification struct {
|
||||
text func(route bot.DesktopWatchRoute) string
|
||||
card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
|
||||
route *bot.DesktopWatchRoute
|
||||
}
|
||||
|
||||
// isSharedChat 判断一个聊天是否为多用户场景(群/话题群/服务器频道)。私聊/单聊
|
||||
// 只有操作者本人,可安全展示命令行等敏感详情。
|
||||
func isSharedChat(ct bot.ChatType) bool {
|
||||
return ct != bot.ChatDM && ct != bot.ChatDirect
|
||||
}
|
||||
|
||||
func constText(s string) func(bot.DesktopWatchRoute) string {
|
||||
return func(bot.DesktopWatchRoute) string { return s }
|
||||
}
|
||||
|
||||
const (
|
||||
botBridgeQueueSize = 64
|
||||
botBridgeSendTimeout = 15 * time.Second
|
||||
botBridgeSubjectLimit = 200
|
||||
botBridgePendingLimit = 200
|
||||
botBridgeErrTextLimit = 300
|
||||
botBridgePromptPreview = 500
|
||||
)
|
||||
|
||||
// botBridgeDeps 打包 hub 对宿主(App)的全部依赖,便于测试注入。
|
||||
type botBridgeDeps struct {
|
||||
sessions func() []bot.DesktopSessionInfo
|
||||
approveTab func(tabID, id string, allow, session, persist bool)
|
||||
answerTab func(tabID, id string, answers []QuestionAnswer)
|
||||
notify func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error)
|
||||
drive func(tabID, text string, route bot.DesktopWatchRoute) error
|
||||
announce func(tabID, text string)
|
||||
persistWatchers func(routes []bot.DesktopWatchRoute) error
|
||||
takeoverChanged func()
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func newBotBridgeHub(deps botBridgeDeps) *botBridgeHub {
|
||||
logger := deps.logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
h := &botBridgeHub{
|
||||
sessions: deps.sessions,
|
||||
approveTab: deps.approveTab,
|
||||
answerTab: deps.answerTab,
|
||||
notify: deps.notify,
|
||||
drive: deps.drive,
|
||||
announce: deps.announce,
|
||||
persistWatchers: deps.persistWatchers,
|
||||
takeoverChanged: deps.takeoverChanged,
|
||||
logger: logger.With("component", "bot_bridge"),
|
||||
watchers: make(map[string]bot.DesktopWatchRoute),
|
||||
pending: make(map[string]desktopPendingPrompt),
|
||||
takeovers: make(map[string]bot.DesktopWatchRoute),
|
||||
takeoverTabs: make(map[string]string),
|
||||
queue: make(chan desktopBridgeNotification, botBridgeQueueSize),
|
||||
}
|
||||
go h.run()
|
||||
return h
|
||||
}
|
||||
|
||||
// observe 接收某个桌面会话的一条事件。在 controller 事件 goroutine 上运行,
|
||||
// 只做内存记账和入队,不做任何阻塞调用。
|
||||
func (h *botBridgeHub) observe(tabID string, e event.Event) {
|
||||
switch e.Kind {
|
||||
case event.ApprovalRequest:
|
||||
h.mu.Lock()
|
||||
h.rememberPendingLocked(e.Approval.ID, desktopPendingPrompt{
|
||||
tabID: tabID,
|
||||
kind: "approval",
|
||||
tool: e.Approval.Tool,
|
||||
subject: truncateForBridge(e.Approval.Subject, botBridgeSubjectLimit),
|
||||
})
|
||||
watching := len(h.watchers) > 0
|
||||
h.mu.Unlock()
|
||||
if watching {
|
||||
h.enqueue(h.approvalNotification(tabID, e.Approval))
|
||||
}
|
||||
case event.AskRequest:
|
||||
h.mu.Lock()
|
||||
h.rememberPendingLocked(e.Ask.ID, desktopPendingPrompt{
|
||||
tabID: tabID,
|
||||
kind: "ask",
|
||||
questions: e.Ask.Questions,
|
||||
})
|
||||
watching := len(h.watchers) > 0
|
||||
h.mu.Unlock()
|
||||
if watching {
|
||||
h.enqueue(h.askNotification(tabID, e.Ask))
|
||||
}
|
||||
case event.TurnDone:
|
||||
h.mu.Lock()
|
||||
for id, p := range h.pending {
|
||||
if p.tabID == tabID {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
}
|
||||
watching := len(h.watchers) > 0
|
||||
h.mu.Unlock()
|
||||
if !watching {
|
||||
return
|
||||
}
|
||||
if e.Err != nil && strings.Contains(e.Err.Error(), "context canceled") {
|
||||
// 桌面端主动停止的任务不推送,避免正常操作变成噪音。
|
||||
return
|
||||
}
|
||||
h.enqueue(h.turnDoneNotification(tabID, e))
|
||||
}
|
||||
}
|
||||
|
||||
// rememberPendingLocked 记录待处理项;容量兜底防泄漏(正常路径 TurnDone 会清理)。
|
||||
func (h *botBridgeHub) rememberPendingLocked(id string, p desktopPendingPrompt) {
|
||||
if strings.TrimSpace(id) == "" {
|
||||
return
|
||||
}
|
||||
if len(h.pending) >= botBridgePendingLimit {
|
||||
h.pending = make(map[string]desktopPendingPrompt)
|
||||
}
|
||||
h.pending[id] = p
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) enqueue(n desktopBridgeNotification) {
|
||||
select {
|
||||
case h.queue <- n:
|
||||
default:
|
||||
h.logger.Warn("desktop bridge notification queue full; dropping")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) run() {
|
||||
for n := range h.queue {
|
||||
h.deliver(n)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) deliver(n desktopBridgeNotification) {
|
||||
h.mu.Lock()
|
||||
var routes []bot.DesktopWatchRoute
|
||||
if n.route != nil {
|
||||
routes = []bot.DesktopWatchRoute{*n.route}
|
||||
} else {
|
||||
routes = h.watcherRoutesLocked()
|
||||
}
|
||||
notify := h.notify
|
||||
h.mu.Unlock()
|
||||
if notify == nil || len(routes) == 0 {
|
||||
return
|
||||
}
|
||||
// Fan out per route: a single slow/hung connection must not hold the queue
|
||||
// worker for its full timeout and back-pressure everyone else's approvals.
|
||||
var wg sync.WaitGroup
|
||||
for _, route := range routes {
|
||||
wg.Add(1)
|
||||
go func(route bot.DesktopWatchRoute) {
|
||||
defer wg.Done()
|
||||
msg := bot.OutboundMessage{
|
||||
ChatID: route.ChatID,
|
||||
ChatType: route.ChatType,
|
||||
}
|
||||
if n.text != nil {
|
||||
msg.Text = n.text(route)
|
||||
}
|
||||
if n.card != nil {
|
||||
msg.Card = n.card(route)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), botBridgeSendTimeout)
|
||||
defer cancel()
|
||||
if _, err := notify(ctx, route.ConnectionID, route.Domain, msg); err != nil {
|
||||
h.logger.Warn("desktop bridge notification send failed", "platform", route.Platform, "err", err)
|
||||
}
|
||||
}(route)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// tabLabel 把 tabID 解析成人类可读的会话名。
|
||||
func (h *botBridgeHub) tabLabel(tabID string) string {
|
||||
if s, ok := h.sessionByTabID(tabID); ok {
|
||||
if label := strings.TrimSpace(s.Label); label != "" {
|
||||
return label
|
||||
}
|
||||
if title := strings.TrimSpace(s.Topic); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return "(未命名会话)"
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) sessionByTabID(tabID string) (bot.DesktopSessionInfo, bool) {
|
||||
if h.sessions == nil {
|
||||
return bot.DesktopSessionInfo{}, false
|
||||
}
|
||||
for _, s := range h.sessions() {
|
||||
if s.TabID == tabID {
|
||||
return s, true
|
||||
}
|
||||
}
|
||||
return bot.DesktopSessionInfo{}, false
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) approvalNotification(tabID string, approval event.Approval) desktopBridgeNotification {
|
||||
label := h.tabLabel(tabID)
|
||||
// The approval subject is the pending command line; only reveal it in a
|
||||
// private chat. In a shared chat show the tool name and point the operator
|
||||
// to the desktop / a DM instead of leaking the command to the whole group.
|
||||
subjectFor := func(route bot.DesktopWatchRoute) string {
|
||||
if isSharedChat(route.ChatType) {
|
||||
return "(命令详情仅在桌面端或私聊显示)"
|
||||
}
|
||||
return truncateForBridge(approval.Subject, botBridgeSubjectLimit)
|
||||
}
|
||||
return desktopBridgeNotification{
|
||||
text: func(route bot.DesktopWatchRoute) string {
|
||||
return fmt.Sprintf("⚠️ 桌面会话「%s」需要批准操作\n工具: %s\n操作: %s\n\nID: `%s`\n用 /desktop approve %s 批准,/desktop deny %s 拒绝。桌面端先处理则以先到者为准。",
|
||||
label, approval.Tool, subjectFor(route), approval.ID, approval.ID, approval.ID)
|
||||
},
|
||||
card: func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
|
||||
return &bot.InteractiveCard{
|
||||
Header: "桌面会话需要批准",
|
||||
Elements: []bot.InteractiveCardElement{
|
||||
{Tag: "markdown", Content: fmt.Sprintf("**会话**: %s\n\n**工具**: %s\n\n**操作**: %s\n\nID: `%s`", label, approval.Tool, subjectFor(route), approval.ID)},
|
||||
{Tag: "action", Extra: map[string]any{
|
||||
"actions": []map[string]any{
|
||||
desktopCardButton("允许一次", "primary", "/desktop approve "+approval.ID, route),
|
||||
desktopCardButton("拒绝", "danger", "/desktop deny "+approval.ID, route),
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) askNotification(tabID string, ask event.Ask) desktopBridgeNotification {
|
||||
label := h.tabLabel(tabID)
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "❓ 桌面会话「%s」在等待回答:\n", label)
|
||||
for i, q := range ask.Questions {
|
||||
fmt.Fprintf(&b, "\n**%d. %s**\n", i+1, truncateForBridge(q.Prompt, botBridgePromptPreview))
|
||||
for j, opt := range q.Options {
|
||||
fmt.Fprintf(&b, " %d. %s\n", j+1, opt.Label)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\nID: `%s`\n用 /desktop answer %s <选项编号或文本> 回答;桌面端先处理则以先到者为准。", ask.ID, ask.ID)
|
||||
privateText := b.String()
|
||||
sharedText := fmt.Sprintf("❓ 桌面会话「%s」正在等待回答(问题详情仅在桌面端或私聊显示)。\n\nID: `%s`", label, ask.ID)
|
||||
textFor := func(route bot.DesktopWatchRoute) string {
|
||||
if isSharedChat(route.ChatType) {
|
||||
return sharedText
|
||||
}
|
||||
return privateText
|
||||
}
|
||||
|
||||
var card func(route bot.DesktopWatchRoute) *bot.InteractiveCard
|
||||
if len(ask.Questions) == 1 && len(ask.Questions[0].Options) > 0 {
|
||||
options := ask.Questions[0].Options
|
||||
card = func(route bot.DesktopWatchRoute) *bot.InteractiveCard {
|
||||
if isSharedChat(route.ChatType) {
|
||||
return nil
|
||||
}
|
||||
actions := make([]map[string]any, 0, len(options))
|
||||
for i, opt := range options {
|
||||
optLabel := strings.TrimSpace(opt.Label)
|
||||
if optLabel == "" {
|
||||
optLabel = fmt.Sprintf("选项 %d", i+1)
|
||||
}
|
||||
actions = append(actions, desktopCardButton(optLabel, "primary", fmt.Sprintf("/desktop answer %s %d", ask.ID, i+1), route))
|
||||
}
|
||||
return &bot.InteractiveCard{
|
||||
Header: "桌面会话在等待回答",
|
||||
Elements: []bot.InteractiveCardElement{
|
||||
{Tag: "markdown", Content: privateText},
|
||||
{Tag: "action", Extra: map[string]any{"actions": actions}},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
return desktopBridgeNotification{text: textFor, card: card}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) turnDoneNotification(tabID string, e event.Event) desktopBridgeNotification {
|
||||
label := h.tabLabel(tabID)
|
||||
if e.Outcome == event.TurnOutcomeRecoveryPaused {
|
||||
return desktopBridgeNotification{text: constText(fmt.Sprintf(
|
||||
"⏸️ 桌面会话「%s」已暂停自动重试。已完成的工作会保留;发送“继续”即可开始新一轮,也可以补充要求调整方向。",
|
||||
label,
|
||||
))}
|
||||
}
|
||||
if e.Err != nil {
|
||||
// Error text can contain paths/tokens; only detail it in a private chat.
|
||||
return desktopBridgeNotification{text: func(route bot.DesktopWatchRoute) string {
|
||||
if isSharedChat(route.ChatType) {
|
||||
return fmt.Sprintf("❌ 桌面会话「%s」任务出错(详情见桌面端或私聊)。", label)
|
||||
}
|
||||
return fmt.Sprintf("❌ 桌面会话「%s」任务出错: %s", label, truncateForBridge(e.Err.Error(), botBridgeErrTextLimit))
|
||||
}}
|
||||
}
|
||||
return desktopBridgeNotification{text: constText(fmt.Sprintf("✅ 桌面会话「%s」任务完成。", label))}
|
||||
}
|
||||
|
||||
func desktopCardButton(label, style, command string, route bot.DesktopWatchRoute) map[string]any {
|
||||
return map[string]any{
|
||||
"tag": "button",
|
||||
"text": map[string]string{"tag": "plain_text", "content": label},
|
||||
"type": style,
|
||||
"value": map[string]string{
|
||||
"command": command,
|
||||
"chat_type": string(route.ChatType),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func truncateForBridge(s string, limit int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
runes := []rune(s)
|
||||
if len(runes) <= limit {
|
||||
return s
|
||||
}
|
||||
return string(runes[:limit]) + "…"
|
||||
}
|
||||
|
||||
// bot.DesktopBridge 实现
|
||||
|
||||
func (h *botBridgeHub) Sessions() []bot.DesktopSessionInfo {
|
||||
if h.sessions == nil {
|
||||
return nil
|
||||
}
|
||||
sessions := h.sessions()
|
||||
h.mu.Lock()
|
||||
byTab := make(map[string][]bot.DesktopPendingInfo, len(h.pending))
|
||||
for id, p := range h.pending {
|
||||
byTab[p.tabID] = append(byTab[p.tabID], bot.DesktopPendingInfo{ID: id, Kind: p.kind, Tool: p.tool})
|
||||
}
|
||||
h.mu.Unlock()
|
||||
for i := range sessions {
|
||||
if pend := byTab[sessions[i].TabID]; len(pend) > 0 {
|
||||
sort.Slice(pend, func(a, b int) bool { return pend[a].ID < pend[b].ID })
|
||||
sessions[i].Pending = pend
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) SetWatch(route bot.DesktopWatchRoute, enable bool) error {
|
||||
h.mu.Lock()
|
||||
if enable {
|
||||
h.watchers[route.Key()] = route
|
||||
} else {
|
||||
delete(h.watchers, route.Key())
|
||||
}
|
||||
h.watchSeq++
|
||||
h.watchPersistDirty = true
|
||||
seq := h.watchSeq
|
||||
routes := h.watcherRoutesLocked()
|
||||
persist := h.persistWatchers
|
||||
h.mu.Unlock()
|
||||
if persist == nil {
|
||||
return nil
|
||||
}
|
||||
// Serialize persists and drop stale ones: two concurrent SetWatch calls
|
||||
// (different connections) compute snapshots under h.mu but write config
|
||||
// outside it, so their writes could otherwise reorder and let an older
|
||||
// snapshot clobber a newer one, silently losing a subscription.
|
||||
h.persistMu.Lock()
|
||||
defer h.persistMu.Unlock()
|
||||
if seq <= h.lastPersistSeq {
|
||||
return nil
|
||||
}
|
||||
if err := persist(routes); err != nil {
|
||||
return err
|
||||
}
|
||||
h.lastPersistSeq = seq
|
||||
h.mu.Lock()
|
||||
if h.watchSeq == seq {
|
||||
h.watchPersistDirty = false
|
||||
}
|
||||
h.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) watcherVersion() uint64 {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.watchSeq
|
||||
}
|
||||
|
||||
// seedWatchers applies a config snapshot only if no watch command changed the
|
||||
// runtime after the config read began. Fresh external config edits still apply;
|
||||
// stale refreshes and failed local persists do not erase newer runtime state.
|
||||
func (h *botBridgeHub) seedWatchers(routes []bot.DesktopWatchRoute, expectedSeq uint64) {
|
||||
h.persistMu.Lock()
|
||||
defer h.persistMu.Unlock()
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.watchSeq != expectedSeq || h.watchPersistDirty {
|
||||
return
|
||||
}
|
||||
h.watchers = make(map[string]bot.DesktopWatchRoute, len(routes))
|
||||
for _, r := range routes {
|
||||
if strings.TrimSpace(r.ChatID) == "" {
|
||||
continue
|
||||
}
|
||||
h.watchers[r.Key()] = r
|
||||
}
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) watcherRoutesLocked() []bot.DesktopWatchRoute {
|
||||
routes := make([]bot.DesktopWatchRoute, 0, len(h.watchers))
|
||||
for _, r := range h.watchers {
|
||||
routes = append(routes, r)
|
||||
}
|
||||
sort.Slice(routes, func(i, j int) bool { return routes[i].Key() < routes[j].Key() })
|
||||
return routes
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) Watching(route bot.DesktopWatchRoute) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
_, ok := h.watchers[route.Key()]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) Approve(approvalID string, allow bool) (string, error) {
|
||||
approvalID = strings.TrimSpace(approvalID)
|
||||
h.mu.Lock()
|
||||
p, ok := h.pending[approvalID]
|
||||
if ok && p.kind == "approval" {
|
||||
delete(h.pending, approvalID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || p.kind != "approval" {
|
||||
return "", fmt.Errorf("未找到待处理的审批 %s(可能已在桌面端处理或已超时)。用 /desktop status 查看当前会话。", approvalID)
|
||||
}
|
||||
if h.approveTab == nil {
|
||||
return "", fmt.Errorf("桌面端审批通道不可用。")
|
||||
}
|
||||
h.approveTab(p.tabID, approvalID, allow, false, false)
|
||||
action := "批准"
|
||||
if !allow {
|
||||
action = "拒绝"
|
||||
}
|
||||
return fmt.Sprintf("已提交%s「%s」的操作(%s)。桌面端若已先处理,以先到者为准。", action, h.tabLabel(p.tabID), p.tool), nil
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) AskQuestions(askID string) ([]event.AskQuestion, bool) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
p, ok := h.pending[strings.TrimSpace(askID)]
|
||||
if !ok || p.kind != "ask" {
|
||||
return nil, false
|
||||
}
|
||||
return p.questions, true
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) Answer(askID string, answers []event.AskAnswer) (string, error) {
|
||||
askID = strings.TrimSpace(askID)
|
||||
h.mu.Lock()
|
||||
p, ok := h.pending[askID]
|
||||
if ok && p.kind == "ask" {
|
||||
delete(h.pending, askID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || p.kind != "ask" {
|
||||
return "", fmt.Errorf("未找到待回答的提问 %s(可能已在桌面端回答或已超时)。", askID)
|
||||
}
|
||||
if h.answerTab == nil {
|
||||
return "", fmt.Errorf("桌面端问答通道不可用。")
|
||||
}
|
||||
out := make([]QuestionAnswer, 0, len(answers))
|
||||
for _, an := range answers {
|
||||
out = append(out, QuestionAnswer{QuestionID: an.QuestionID, Selected: an.Selected})
|
||||
}
|
||||
h.answerTab(p.tabID, askID, out)
|
||||
return fmt.Sprintf("已提交「%s」的回答。桌面端若已先处理,以先到者为准。", h.tabLabel(p.tabID)), nil
|
||||
}
|
||||
|
||||
// 显式接管
|
||||
|
||||
func (h *botBridgeHub) Takeover(route bot.DesktopWatchRoute, tabID string) (string, error) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
// DM only. In a group the binding is keyed on the group chat, so after an
|
||||
// admin takes over, ANY allowlisted member's plain message would be diverted
|
||||
// to drive the session — a privilege escalation past the admin gate that
|
||||
// establishes the takeover. Restricting to DM keeps the driver identical to
|
||||
// the operator who established it.
|
||||
if route.ChatType != bot.ChatDM {
|
||||
return "", fmt.Errorf("接管仅支持私聊:在群里接管会让其他成员也能驱动你的桌面会话。请在与 bot 的私聊中接管。")
|
||||
}
|
||||
session, ok := h.sessionByTabID(tabID)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("未找到会话 %s。用 /desktop status 查看可接管的会话。", tabID)
|
||||
}
|
||||
if session.Detached {
|
||||
return "", fmt.Errorf("会话「%s」在后台运行,暂不支持接管;请先在桌面端打开它。", h.tabLabel(tabID))
|
||||
}
|
||||
h.mu.Lock()
|
||||
if holder, held := h.takeovers[tabID]; held && holder.Key() != route.Key() {
|
||||
h.mu.Unlock()
|
||||
return "", fmt.Errorf("会话「%s」已被另一个聊天接管。", h.tabLabel(tabID))
|
||||
}
|
||||
// 同一聊天换目标:先解除旧绑定,并记下旧 tab 以便公告解除。
|
||||
released := ""
|
||||
if prev, ok := h.takeoverTabs[route.Key()]; ok && prev != tabID {
|
||||
delete(h.takeovers, prev)
|
||||
released = prev
|
||||
}
|
||||
h.takeovers[tabID] = route
|
||||
h.takeoverTabs[route.Key()] = tabID
|
||||
announce := h.announce
|
||||
changed := h.takeoverChanged
|
||||
h.mu.Unlock()
|
||||
if announce != nil {
|
||||
if released != "" {
|
||||
announce(released, "IM 远程接管已解除(接管方切换到了另一个会话)。")
|
||||
}
|
||||
announce(tabID, "此会话已被 IM 远程接管(bot 管理员)。在此本地发送任意消息即可收回控制。")
|
||||
}
|
||||
if changed != nil {
|
||||
changed()
|
||||
}
|
||||
label := h.tabLabel(tabID)
|
||||
return fmt.Sprintf("已接管「%s」。现在直接发消息即可驱动它,输出会流回本聊天;/desktop release 解除接管。桌面端本地发言会自动收回控制。", label), nil
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) Release(route bot.DesktopWatchRoute) (string, error) {
|
||||
h.mu.Lock()
|
||||
tabID, ok := h.takeoverTabs[route.Key()]
|
||||
if ok {
|
||||
delete(h.takeoverTabs, route.Key())
|
||||
delete(h.takeovers, tabID)
|
||||
}
|
||||
announce := h.announce
|
||||
changed := h.takeoverChanged
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("本聊天当前没有接管任何桌面会话。")
|
||||
}
|
||||
if announce != nil {
|
||||
announce(tabID, "IM 远程接管已解除。")
|
||||
}
|
||||
if changed != nil {
|
||||
changed()
|
||||
}
|
||||
return fmt.Sprintf("已解除对「%s」的接管。", h.tabLabel(tabID)), nil
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) TakeoverTab(route bot.DesktopWatchRoute) string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.takeoverTabs[route.Key()]
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) DriveInput(route bot.DesktopWatchRoute, text string) (string, error) {
|
||||
h.mu.Lock()
|
||||
tabID := h.takeoverTabs[route.Key()]
|
||||
h.mu.Unlock()
|
||||
if tabID == "" {
|
||||
return "", fmt.Errorf("本聊天没有接管任何桌面会话。")
|
||||
}
|
||||
session, ok := h.sessionByTabID(tabID)
|
||||
if !ok || session.Detached {
|
||||
// 会话被关闭或转入后台:自动解除绑定,避免消息黑洞。
|
||||
h.mu.Lock()
|
||||
delete(h.takeoverTabs, route.Key())
|
||||
delete(h.takeovers, tabID)
|
||||
h.mu.Unlock()
|
||||
if changed := h.takeoverChanged; changed != nil {
|
||||
changed()
|
||||
}
|
||||
return "", fmt.Errorf("被接管的会话已关闭或转入后台,接管已自动解除。")
|
||||
}
|
||||
if session.Running {
|
||||
return "", h.busyError(tabID)
|
||||
}
|
||||
if h.drive == nil {
|
||||
return "", fmt.Errorf("桌面端驱动通道不可用。")
|
||||
}
|
||||
if err := h.drive(tabID, text, route); err != nil {
|
||||
if errors.Is(err, errDriveBusy) {
|
||||
return "", h.busyError(tabID)
|
||||
}
|
||||
return "", fmt.Errorf("驱动失败: %w", err)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func (h *botBridgeHub) busyError(tabID string) error {
|
||||
return fmt.Errorf("会话「%s」正在执行中,等它完成后再发;或用 /desktop watch on 订阅完成通知。", h.tabLabel(tabID))
|
||||
}
|
||||
|
||||
// reclaimFromDesktop 在桌面用户本地提交输入时收回控制权:解除绑定并通知
|
||||
// 远端聊天。由 App.SubmitToTab 调用(bridge 自己的驱动不走这条路)。
|
||||
func (h *botBridgeHub) reclaimFromDesktop(tabID string) {
|
||||
h.mu.Lock()
|
||||
route, ok := h.takeovers[tabID]
|
||||
if ok {
|
||||
delete(h.takeovers, tabID)
|
||||
delete(h.takeoverTabs, route.Key())
|
||||
}
|
||||
notify := h.notify
|
||||
changed := h.takeoverChanged
|
||||
h.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if changed != nil {
|
||||
changed()
|
||||
}
|
||||
if notify == nil {
|
||||
return
|
||||
}
|
||||
label := h.tabLabel(tabID)
|
||||
// 直接入通知队列(不依赖 watch 订阅):接管者必须知道控制权没了。
|
||||
h.enqueue(desktopBridgeNotification{
|
||||
text: constText(fmt.Sprintf("🔓 桌面端已收回会话「%s」的控制权,接管已解除。", label)),
|
||||
route: &route,
|
||||
})
|
||||
}
|
||||
|
||||
// remoteControlledTabs 返回当前被接管的 tabID 集合(TabMeta 标记用)。
|
||||
func (h *botBridgeHub) remoteControlledTabs() map[string]bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if len(h.takeovers) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]bool, len(h.takeovers))
|
||||
for tabID := range h.takeovers {
|
||||
out[tabID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,184 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// 本文件是 botBridgeHub 对 App 的全部胶水:会话枚举(含后台 detached)、
|
||||
// 按 tab 寻址的审批/问答/驱动、transcript 公告、订阅持久化。
|
||||
|
||||
func (a *App) newBotBridge() *botBridgeHub {
|
||||
return newBotBridgeHub(botBridgeDeps{
|
||||
sessions: a.bridgeSessions,
|
||||
approveTab: a.bridgeApprove,
|
||||
answerTab: a.bridgeAnswer,
|
||||
notify: a.botRuntime.SendToAdapter,
|
||||
drive: a.bridgeDrive,
|
||||
announce: a.bridgeAnnounce,
|
||||
persistWatchers: a.bridgePersistWatchers,
|
||||
takeoverChanged: a.emitProjectTreeChanged,
|
||||
logger: slog.Default(),
|
||||
})
|
||||
}
|
||||
|
||||
// bridgeSessions 枚举所有 live 会话:可见 tab 用完整 TabMeta,后台 detached
|
||||
// 会话补一份轻量快照(controller 仍存活,审批/问答仍可路由)。
|
||||
func (a *App) bridgeSessions() []bot.DesktopSessionInfo {
|
||||
tabs := a.ListTabs()
|
||||
out := make([]bot.DesktopSessionInfo, 0, len(tabs)+4)
|
||||
seen := make(map[string]bool, len(tabs))
|
||||
for _, t := range tabs {
|
||||
seen[t.ID] = true
|
||||
out = append(out, bot.DesktopSessionInfo{
|
||||
TabID: t.ID,
|
||||
Label: t.Label,
|
||||
Workspace: t.WorkspaceName,
|
||||
Topic: t.TopicTitle,
|
||||
Ready: t.Ready,
|
||||
Running: t.Running,
|
||||
PendingPrompt: t.PendingPrompt,
|
||||
})
|
||||
}
|
||||
a.mu.RLock()
|
||||
for _, tab := range a.detachedSessions {
|
||||
if tab == nil || seen[tab.ID] {
|
||||
continue
|
||||
}
|
||||
seen[tab.ID] = true
|
||||
out = append(out, bot.DesktopSessionInfo{
|
||||
TabID: tab.ID,
|
||||
Label: tab.TopicTitle,
|
||||
Topic: tab.TopicTitle,
|
||||
Ready: tab.Ctrl != nil,
|
||||
Running: strings.TrimSpace(tab.ActivityStatus) != "",
|
||||
Detached: true,
|
||||
})
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
return out
|
||||
}
|
||||
|
||||
// bridgeCtrlByTabID 解析可见与后台 detached 两张表(区别于 ctrlByTabID:
|
||||
// 那是前端语义,空 tabID 落到活跃 tab,且不看 detached)。
|
||||
func (a *App) bridgeCtrlByTabID(tabID string) control.SessionAPI {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
if tab := a.tabByEventSinkIDLocked(tabID); tab != nil {
|
||||
return tab.Ctrl
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) bridgeApprove(tabID, id string, allow, session, persist bool) {
|
||||
if ctrl := a.bridgeCtrlByTabID(tabID); ctrl != nil {
|
||||
ctrl.Approve(id, allow, session, persist)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) bridgeAnswer(tabID, id string, answers []QuestionAnswer) {
|
||||
ctrl := a.bridgeCtrlByTabID(tabID)
|
||||
if ctrl == nil {
|
||||
return
|
||||
}
|
||||
out := make([]event.AskAnswer, len(answers))
|
||||
for i, an := range answers {
|
||||
out[i] = event.AskAnswer{QuestionID: an.QuestionID, Selected: an.Selected}
|
||||
}
|
||||
ctrl.AnswerQuestion(id, out)
|
||||
}
|
||||
|
||||
// bridgeAnnounce 往会话 transcript 发一条 Notice,桌面用户在聊天流里可见。
|
||||
func (a *App) bridgeAnnounce(tabID, text string) {
|
||||
a.mu.RLock()
|
||||
tab := a.tabByEventSinkIDLocked(tabID)
|
||||
var sink *tabEventSink
|
||||
if tab != nil {
|
||||
sink = tab.sink
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if sink == nil {
|
||||
return
|
||||
}
|
||||
sink.Emit(event.Event{Kind: event.Notice, Level: event.LevelWarn, Text: text})
|
||||
}
|
||||
|
||||
// bridgeDrive 把远程文本提交为可见 tab 的新 turn,并为这一轮挂上事件转发器,
|
||||
// 让输出流回接管聊天(转发器在 TurnDone 自动卸载)。
|
||||
func (a *App) bridgeDrive(tabID, text string, route bot.DesktopWatchRoute) error {
|
||||
admission, ctrl, err := a.beginTabTurn(tabID, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, control.ErrTurnRunning) {
|
||||
return errDriveBusy
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer admission.abort()
|
||||
tab := admission.tab
|
||||
if tab.sink == nil {
|
||||
return fmt.Errorf("会话事件通道不可用,无法驱动")
|
||||
}
|
||||
// A local submission may have reclaimed the tab while this drive was waiting
|
||||
// for the per-tab admission gate. Revalidate ownership only after the gate is
|
||||
// held, immediately before attaching the route-specific forwarder.
|
||||
if a.botBridge == nil || a.botBridge.TakeoverTab(route) != tabID {
|
||||
return fmt.Errorf("接管已解除,请重新接管会话")
|
||||
}
|
||||
target := botForwardTarget{
|
||||
ConnID: route.ConnectionID,
|
||||
Domain: route.Domain,
|
||||
ChatID: route.ChatID,
|
||||
ChatType: route.ChatType,
|
||||
}
|
||||
generation := tab.sink.SetBotSink(newBotEventForwarder(a.botRuntime, []botForwardTarget{target}))
|
||||
a.ensureTabTopicIndexedForUserTurn(tab)
|
||||
ctrl.SubmitDisplay(text, text)
|
||||
// Confirm the submit actually started a turn. If nothing is running now, the
|
||||
// controller was rotating and the submit no-oped — detach this exact
|
||||
// generation so a later turn's output does not leak.
|
||||
if !admission.finish(ctrl) {
|
||||
tab.sink.clearBotSink(generation)
|
||||
return errDriveBusy
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// bridgePersistWatchers 把订阅全集回写用户配置(bot.desktop_watchers),
|
||||
// 桌面重启后由 refreshBotRuntime 重新种子。
|
||||
func (a *App) bridgePersistWatchers(routes []bot.DesktopWatchRoute) error {
|
||||
return a.applyConfigOnly(func(c *config.Config) error {
|
||||
watchers := make([]config.BotDesktopWatcherConfig, 0, len(routes))
|
||||
for _, r := range routes {
|
||||
watchers = append(watchers, config.BotDesktopWatcherConfig{
|
||||
Platform: string(r.Platform),
|
||||
ConnectionID: r.ConnectionID,
|
||||
Domain: r.Domain,
|
||||
ChatType: string(r.ChatType),
|
||||
ChatID: r.ChatID,
|
||||
})
|
||||
}
|
||||
c.Bot.DesktopWatchers = watchers
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func bridgeRoutesFromConfig(watchers []config.BotDesktopWatcherConfig) []bot.DesktopWatchRoute {
|
||||
routes := make([]bot.DesktopWatchRoute, 0, len(watchers))
|
||||
for _, w := range watchers {
|
||||
routes = append(routes, bot.DesktopWatchRoute{
|
||||
Platform: bot.Platform(strings.TrimSpace(w.Platform)),
|
||||
ConnectionID: strings.TrimSpace(w.ConnectionID),
|
||||
Domain: strings.TrimSpace(w.Domain),
|
||||
ChatType: bot.ChatType(strings.TrimSpace(w.ChatType)),
|
||||
ChatID: strings.TrimSpace(w.ChatID),
|
||||
})
|
||||
}
|
||||
return routes
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type bridgeNotifyCall struct {
|
||||
connectionID string
|
||||
domain string
|
||||
msg bot.OutboundMessage
|
||||
}
|
||||
|
||||
type bridgeTestEnv struct {
|
||||
hub *botBridgeHub
|
||||
notified chan bridgeNotifyCall
|
||||
approves chan [2]string // [tabID, id+":"+allow]
|
||||
answers chan [2]string // [tabID, id]
|
||||
driven chan [2]string // [tabID, text]
|
||||
announced chan [2]string // [tabID, text]
|
||||
persisted chan []bot.DesktopWatchRoute
|
||||
driveErr error
|
||||
persistErr error
|
||||
}
|
||||
|
||||
func tabsToSessions(tabs []TabMeta) []bot.DesktopSessionInfo {
|
||||
out := make([]bot.DesktopSessionInfo, 0, len(tabs))
|
||||
for _, t := range tabs {
|
||||
out = append(out, bot.DesktopSessionInfo{
|
||||
TabID: t.ID,
|
||||
Label: t.Label,
|
||||
Workspace: t.WorkspaceName,
|
||||
Topic: t.TopicTitle,
|
||||
Ready: t.Ready,
|
||||
Running: t.Running,
|
||||
PendingPrompt: t.PendingPrompt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func newBridgeTestEnv(tabs []TabMeta) *bridgeTestEnv {
|
||||
return newBridgeTestEnvSessions(tabsToSessions(tabs))
|
||||
}
|
||||
|
||||
func newBridgeTestEnvSessions(sessions []bot.DesktopSessionInfo) *bridgeTestEnv {
|
||||
env := &bridgeTestEnv{
|
||||
notified: make(chan bridgeNotifyCall, 16),
|
||||
approves: make(chan [2]string, 16),
|
||||
answers: make(chan [2]string, 16),
|
||||
driven: make(chan [2]string, 16),
|
||||
announced: make(chan [2]string, 16),
|
||||
persisted: make(chan []bot.DesktopWatchRoute, 16),
|
||||
}
|
||||
env.hub = newBotBridgeHub(botBridgeDeps{
|
||||
sessions: func() []bot.DesktopSessionInfo { return sessions },
|
||||
approveTab: func(tabID, id string, allow, session, persist bool) {
|
||||
env.approves <- [2]string{tabID, fmt.Sprintf("%s:%t", id, allow)}
|
||||
},
|
||||
answerTab: func(tabID, id string, answers []QuestionAnswer) {
|
||||
env.answers <- [2]string{tabID, id}
|
||||
},
|
||||
notify: func(ctx context.Context, connectionID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
env.notified <- bridgeNotifyCall{connectionID: connectionID, domain: domain, msg: msg}
|
||||
return bot.SendResult{MessageID: "sent-1"}, nil
|
||||
},
|
||||
drive: func(tabID, text string, route bot.DesktopWatchRoute) error {
|
||||
if env.driveErr != nil {
|
||||
return env.driveErr
|
||||
}
|
||||
env.driven <- [2]string{tabID, text}
|
||||
return nil
|
||||
},
|
||||
announce: func(tabID, text string) {
|
||||
env.announced <- [2]string{tabID, text}
|
||||
},
|
||||
persistWatchers: func(routes []bot.DesktopWatchRoute) error {
|
||||
env.persisted <- routes
|
||||
return env.persistErr
|
||||
},
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
})
|
||||
return env
|
||||
}
|
||||
|
||||
func (env *bridgeTestEnv) waitNotification(t *testing.T) bridgeNotifyCall {
|
||||
t.Helper()
|
||||
select {
|
||||
case call := <-env.notified:
|
||||
return call
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for bridge notification")
|
||||
return bridgeNotifyCall{}
|
||||
}
|
||||
}
|
||||
|
||||
func (env *bridgeTestEnv) expectNoNotification(t *testing.T) {
|
||||
t.Helper()
|
||||
select {
|
||||
case call := <-env.notified:
|
||||
t.Fatalf("unexpected notification: %+v", call)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func testWatchRoute() bot.DesktopWatchRoute {
|
||||
return bot.DesktopWatchRoute{
|
||||
ConnectionID: "feishu-main",
|
||||
Domain: "feishu",
|
||||
Platform: bot.PlatformFeishu,
|
||||
ChatType: bot.ChatDM,
|
||||
ChatID: "chat-god",
|
||||
}
|
||||
}
|
||||
|
||||
func testGroupRoute() bot.DesktopWatchRoute {
|
||||
r := testWatchRoute()
|
||||
r.ChatType = bot.ChatGroup
|
||||
r.ChatID = "group-god"
|
||||
return r
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverRejectsGroupChat(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
|
||||
if _, err := env.hub.Takeover(testGroupRoute(), "tab-1"); err == nil {
|
||||
t.Fatal("takeover from a group chat must be rejected (non-admin members could otherwise drive it)")
|
||||
}
|
||||
if _, err := env.hub.Takeover(testWatchRoute(), "tab-1"); err != nil {
|
||||
t.Fatalf("DM takeover should work: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverSwitchAnnouncesReleaseToOldTab(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
|
||||
{TabID: "tab-a", Label: "A", Ready: true},
|
||||
{TabID: "tab-b", Label: "B", Ready: true},
|
||||
})
|
||||
route := testWatchRoute()
|
||||
if _, err := env.hub.Takeover(route, "tab-a"); err != nil {
|
||||
t.Fatalf("takeover A: %v", err)
|
||||
}
|
||||
if got := <-env.announced; got[0] != "tab-a" {
|
||||
t.Fatalf("first announce = %v, want tab-a takeover", got)
|
||||
}
|
||||
if _, err := env.hub.Takeover(route, "tab-b"); err != nil {
|
||||
t.Fatalf("switch to B: %v", err)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for range 2 {
|
||||
select {
|
||||
case got := <-env.announced:
|
||||
seen[got[0]] = true
|
||||
case <-time.After(time.Second):
|
||||
t.Fatalf("missing announce after switch; seen=%v", seen)
|
||||
}
|
||||
}
|
||||
if !seen["tab-a"] || !seen["tab-b"] {
|
||||
t.Fatalf("switch should announce release to tab-a and takeover to tab-b; seen=%v", seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeDriveInputBusyReturnsBusyMessage(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一", Ready: true}})
|
||||
route := testWatchRoute()
|
||||
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
|
||||
t.Fatalf("Takeover: %v", err)
|
||||
}
|
||||
<-env.announced
|
||||
env.driveErr = errDriveBusy
|
||||
_, err := env.hub.DriveInput(route, "hi")
|
||||
if err == nil || !strings.Contains(err.Error(), "正在执行中") {
|
||||
t.Fatalf("busy drive should surface a clean busy message, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeApprovalRedactsSubjectInGroup(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testGroupRoute(), true)
|
||||
<-env.persisted
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf /secret"}})
|
||||
call := env.waitNotification(t)
|
||||
if strings.Contains(call.msg.Text, "rm -rf /secret") {
|
||||
t.Fatalf("group notification leaked the command line: %q", call.msg.Text)
|
||||
}
|
||||
if call.msg.Card != nil {
|
||||
for _, el := range call.msg.Card.Elements {
|
||||
if strings.Contains(el.Content, "rm -rf /secret") {
|
||||
t.Fatal("group card leaked the command line")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeApprovalShowsSubjectInDM(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
<-env.persisted
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash", Subject: "rm -rf build"}})
|
||||
call := env.waitNotification(t)
|
||||
if !strings.Contains(call.msg.Text, "rm -rf build") {
|
||||
t.Fatalf("DM notification should show the command line: %q", call.msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeAskRedactsPromptAndOptionsInGroup(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
|
||||
if err := env.hub.SetWatch(testGroupRoute(), true); err != nil {
|
||||
t.Fatalf("SetWatch: %v", err)
|
||||
}
|
||||
<-env.persisted
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{
|
||||
ID: "redacted-ask",
|
||||
Questions: []event.AskQuestion{{
|
||||
ID: "q1", Prompt: "INTERNAL_ONLY_PROMPT", Options: []event.AskOption{{Label: "CHOICE_INTERNAL"}},
|
||||
}},
|
||||
}})
|
||||
call := env.waitNotification(t)
|
||||
if strings.Contains(call.msg.Text, "INTERNAL_ONLY_PROMPT") || strings.Contains(call.msg.Text, "CHOICE_INTERNAL") {
|
||||
t.Fatalf("group notification leaked ask details: %q", call.msg.Text)
|
||||
}
|
||||
if call.msg.Card != nil {
|
||||
t.Fatal("group ask notification must not include option buttons")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSessionsIncludePendingIDs(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{{TabID: "tab-1", Label: "会话一"}})
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "a1", Tool: "bash"}})
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.AskRequest, Ask: event.Ask{ID: "q1", Questions: []event.AskQuestion{{ID: "x", Prompt: "?"}}}})
|
||||
found := map[string]bool{}
|
||||
for _, s := range env.hub.Sessions() {
|
||||
if s.TabID != "tab-1" {
|
||||
continue
|
||||
}
|
||||
for _, p := range s.Pending {
|
||||
found[p.ID] = true
|
||||
}
|
||||
}
|
||||
if !found["a1"] || !found["q1"] {
|
||||
t.Fatalf("Sessions() should surface pending approval and ask ids; got %v", found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgePersistDropsStaleSnapshot(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions(nil)
|
||||
// Two subscribes: the second (newer seq) must be the persisted result even
|
||||
// though we invoke the seed-restore afterward.
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
<-env.persisted
|
||||
env.hub.SetWatch(testGroupRoute(), true)
|
||||
routes := <-env.persisted
|
||||
if len(routes) != 2 {
|
||||
t.Fatalf("persisted routes = %d, want both subscriptions", len(routes))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeApprovalNotifiesWatchersAndRoutesApproval(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "修复登录"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "appr-1", Tool: "bash", Subject: "rm -rf build",
|
||||
}})
|
||||
|
||||
call := env.waitNotification(t)
|
||||
if call.connectionID != "feishu-main" || call.msg.ChatID != "chat-god" {
|
||||
t.Fatalf("notification routed to %s/%s, want feishu-main/chat-god", call.connectionID, call.msg.ChatID)
|
||||
}
|
||||
for _, want := range []string{"修复登录", "bash", "rm -rf build", "/desktop approve appr-1"} {
|
||||
if !strings.Contains(call.msg.Text, want) {
|
||||
t.Fatalf("notification text = %q, want it to contain %q", call.msg.Text, want)
|
||||
}
|
||||
}
|
||||
if call.msg.Card == nil {
|
||||
t.Fatal("approval notification should carry an interactive card")
|
||||
}
|
||||
|
||||
feedback, err := env.hub.Approve("appr-1", true)
|
||||
if err != nil {
|
||||
t.Fatalf("Approve: %v", err)
|
||||
}
|
||||
if !strings.Contains(feedback, "先到者为准") {
|
||||
t.Fatalf("feedback = %q, want first-wins note", feedback)
|
||||
}
|
||||
select {
|
||||
case got := <-env.approves:
|
||||
if got[0] != "tab-1" || got[1] != "appr-1:true" {
|
||||
t.Fatalf("approve routed as %v, want tab-1/appr-1:true", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("approve was not routed to the tab")
|
||||
}
|
||||
|
||||
// 同一 ID 第二次应答:pending 已清,返回未找到。
|
||||
if _, err := env.hub.Approve("appr-1", false); err == nil {
|
||||
t.Fatal("second Approve on the same id should fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgePendingRecordedWithoutWatchers(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话"}})
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-2", Tool: "bash"}})
|
||||
env.expectNoNotification(t)
|
||||
|
||||
if _, err := env.hub.Approve("appr-2", false); err != nil {
|
||||
t.Fatalf("Approve without watchers should still work: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-env.approves:
|
||||
if got[1] != "appr-2:false" {
|
||||
t.Fatalf("deny routed as %v", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("deny was not routed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTurnDoneClearsPendingAndNotifies(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-3", Tool: "bash"}})
|
||||
env.waitNotification(t)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone})
|
||||
call := env.waitNotification(t)
|
||||
if !strings.Contains(call.msg.Text, "✅") || !strings.Contains(call.msg.Text, "会话一") {
|
||||
t.Fatalf("turn-done text = %q", call.msg.Text)
|
||||
}
|
||||
|
||||
if _, err := env.hub.Approve("appr-3", true); err == nil {
|
||||
t.Fatal("pending approval should be cleared by TurnDone")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSuppressesCanceledTurnAndErrorsNotify(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("context canceled")})
|
||||
env.expectNoNotification(t)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{Kind: event.TurnDone, Err: errors.New("boom")})
|
||||
call := env.waitNotification(t)
|
||||
if !strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "boom") {
|
||||
t.Fatalf("error text = %q", call.msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeRecoveryPauseNotifiesAsControlledPause(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-1", Label: "会话一"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-1", event.Event{
|
||||
Kind: event.TurnDone,
|
||||
Err: errors.New("automatic recovery paused"),
|
||||
Outcome: event.TurnOutcomeRecoveryPaused,
|
||||
})
|
||||
call := env.waitNotification(t)
|
||||
if strings.Contains(call.msg.Text, "❌") || !strings.Contains(call.msg.Text, "已暂停自动重试") || !strings.Contains(call.msg.Text, "继续") {
|
||||
t.Fatalf("recovery pause text = %q, want a neutral actionable pause notice", call.msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeAskAnswerRoundTrip(t *testing.T) {
|
||||
env := newBridgeTestEnv([]TabMeta{{ID: "tab-2", Label: "问答会话"}})
|
||||
env.hub.SetWatch(testWatchRoute(), true)
|
||||
|
||||
env.hub.observe("tab-2", event.Event{Kind: event.AskRequest, Ask: event.Ask{
|
||||
ID: "ask-1",
|
||||
Questions: []event.AskQuestion{{
|
||||
ID: "q1",
|
||||
Prompt: "选一个方案",
|
||||
Options: []event.AskOption{{Label: "A"}, {Label: "B"}},
|
||||
}},
|
||||
}})
|
||||
call := env.waitNotification(t)
|
||||
if !strings.Contains(call.msg.Text, "/desktop answer ask-1") {
|
||||
t.Fatalf("ask notification = %q, want answer hint", call.msg.Text)
|
||||
}
|
||||
if call.msg.Card == nil {
|
||||
t.Fatal("single-choice ask should carry option buttons")
|
||||
}
|
||||
|
||||
questions, ok := env.hub.AskQuestions("ask-1")
|
||||
if !ok || len(questions) != 1 {
|
||||
t.Fatalf("AskQuestions = %v/%v", questions, ok)
|
||||
}
|
||||
if _, err := env.hub.Answer("ask-1", []event.AskAnswer{{QuestionID: "q1", Selected: []string{"B"}}}); err != nil {
|
||||
t.Fatalf("Answer: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-env.answers:
|
||||
if got[0] != "tab-2" || got[1] != "ask-1" {
|
||||
t.Fatalf("answer routed as %v", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("answer was not routed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeWatchLifecycleStopsNotifications(t *testing.T) {
|
||||
env := newBridgeTestEnv(nil)
|
||||
route := testWatchRoute()
|
||||
|
||||
env.hub.SetWatch(route, true)
|
||||
if !env.hub.Watching(route) {
|
||||
t.Fatal("route should be watching after SetWatch(true)")
|
||||
}
|
||||
env.hub.SetWatch(route, false)
|
||||
if env.hub.Watching(route) {
|
||||
t.Fatal("route should not be watching after SetWatch(false)")
|
||||
}
|
||||
|
||||
env.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
|
||||
env.expectNoNotification(t)
|
||||
}
|
||||
|
||||
func TestBridgeSetWatchPersistsAndSeedRestores(t *testing.T) {
|
||||
env := newBridgeTestEnv(nil)
|
||||
route := testWatchRoute()
|
||||
|
||||
env.hub.SetWatch(route, true)
|
||||
select {
|
||||
case routes := <-env.persisted:
|
||||
if len(routes) != 1 || routes[0].Key() != route.Key() {
|
||||
t.Fatalf("persisted = %+v, want the subscribed route", routes)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("SetWatch did not persist watchers")
|
||||
}
|
||||
|
||||
// 模拟重启:全新 hub 从配置种子恢复。
|
||||
env2 := newBridgeTestEnv(nil)
|
||||
env2.hub.seedWatchers([]bot.DesktopWatchRoute{route}, env2.hub.watcherVersion())
|
||||
if !env2.hub.Watching(route) {
|
||||
t.Fatal("seeded hub should be watching the persisted route")
|
||||
}
|
||||
env2.hub.observe("tab-x", event.Event{Kind: event.TurnDone})
|
||||
if call := env2.waitNotification(t); !strings.Contains(call.msg.Text, "✅") {
|
||||
t.Fatalf("seeded watcher did not receive notifications: %q", call.msg.Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSeedDoesNotOverwriteNewerRuntimeWatch(t *testing.T) {
|
||||
env := newBridgeTestEnv(nil)
|
||||
route := testWatchRoute()
|
||||
staleVersion := env.hub.watcherVersion()
|
||||
if err := env.hub.SetWatch(route, true); err != nil {
|
||||
t.Fatalf("SetWatch: %v", err)
|
||||
}
|
||||
<-env.persisted
|
||||
|
||||
// Simulate a runtime refresh carrying a config snapshot loaded before the
|
||||
// watch command persisted. It must not erase the newer in-process route.
|
||||
env.hub.seedWatchers(nil, staleVersion)
|
||||
if !env.hub.Watching(route) {
|
||||
t.Fatal("stale config seed overwrote the newer runtime subscription")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSeedPreservesWatchAfterPersistFailure(t *testing.T) {
|
||||
env := newBridgeTestEnv(nil)
|
||||
env.persistErr = errors.New("disk unavailable")
|
||||
route := testWatchRoute()
|
||||
if err := env.hub.SetWatch(route, true); err == nil {
|
||||
t.Fatal("SetWatch should report the persistence failure")
|
||||
}
|
||||
<-env.persisted
|
||||
|
||||
env.hub.seedWatchers(nil, env.hub.watcherVersion())
|
||||
if !env.hub.Watching(route) {
|
||||
t.Fatal("disk snapshot erased a runtime watch whose persistence failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeSeedAppliesFreshExternalConfig(t *testing.T) {
|
||||
env := newBridgeTestEnv(nil)
|
||||
route := testWatchRoute()
|
||||
version := env.hub.watcherVersion()
|
||||
env.hub.seedWatchers([]bot.DesktopWatchRoute{route}, version)
|
||||
if !env.hub.Watching(route) {
|
||||
t.Fatal("initial config seed did not apply")
|
||||
}
|
||||
|
||||
env.hub.seedWatchers(nil, version)
|
||||
if env.hub.Watching(route) {
|
||||
t.Fatal("fresh external config update did not replace the watcher set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeApprovalRoutesToDetachedSession(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
|
||||
{TabID: "tab-bg", Label: "后台任务", Detached: true, Ready: true},
|
||||
})
|
||||
|
||||
env.hub.observe("tab-bg", event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{ID: "appr-bg", Tool: "bash"}})
|
||||
if _, err := env.hub.Approve("appr-bg", true); err != nil {
|
||||
t.Fatalf("Approve on detached session: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-env.approves:
|
||||
if got[0] != "tab-bg" {
|
||||
t.Fatalf("approve routed to %v, want tab-bg", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("detached approval was not routed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeTakeoverLifecycle(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
|
||||
{TabID: "tab-1", Label: "会话一", Ready: true},
|
||||
{TabID: "tab-bg", Label: "后台", Detached: true},
|
||||
})
|
||||
route := testWatchRoute()
|
||||
|
||||
// 后台会话拒绝接管。
|
||||
if _, err := env.hub.Takeover(route, "tab-bg"); err == nil {
|
||||
t.Fatal("takeover of a detached session should fail")
|
||||
}
|
||||
|
||||
feedback, err := env.hub.Takeover(route, "tab-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Takeover: %v", err)
|
||||
}
|
||||
if !strings.Contains(feedback, "已接管") {
|
||||
t.Fatalf("feedback = %q", feedback)
|
||||
}
|
||||
if env.hub.TakeoverTab(route) != "tab-1" {
|
||||
t.Fatalf("TakeoverTab = %q, want tab-1", env.hub.TakeoverTab(route))
|
||||
}
|
||||
select {
|
||||
case got := <-env.announced:
|
||||
if got[0] != "tab-1" || !strings.Contains(got[1], "接管") {
|
||||
t.Fatalf("announce = %v", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("takeover was not announced to the desktop transcript")
|
||||
}
|
||||
|
||||
// 驱动输入路由到 tab。
|
||||
if _, err := env.hub.DriveInput(route, "跑一下测试"); err != nil {
|
||||
t.Fatalf("DriveInput: %v", err)
|
||||
}
|
||||
select {
|
||||
case got := <-env.driven:
|
||||
if got[0] != "tab-1" || got[1] != "跑一下测试" {
|
||||
t.Fatalf("driven = %v", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("drive input was not routed")
|
||||
}
|
||||
|
||||
// 另一个聊天抢同一会话被拒。
|
||||
other := route
|
||||
other.ChatID = "chat-other"
|
||||
if _, err := env.hub.Takeover(other, "tab-1"); err == nil {
|
||||
t.Fatal("takeover by another chat should be rejected while held")
|
||||
}
|
||||
|
||||
// 释放。
|
||||
if _, err := env.hub.Release(route); err != nil {
|
||||
t.Fatalf("Release: %v", err)
|
||||
}
|
||||
if env.hub.TakeoverTab(route) != "" {
|
||||
t.Fatal("binding should be cleared after release")
|
||||
}
|
||||
if _, err := env.hub.Release(route); err == nil {
|
||||
t.Fatal("second release should report no binding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeDriveInputRejectsRunningSession(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
|
||||
{TabID: "tab-1", Label: "会话一", Ready: true, Running: true},
|
||||
})
|
||||
route := testWatchRoute()
|
||||
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
|
||||
t.Fatalf("Takeover: %v", err)
|
||||
}
|
||||
<-env.announced
|
||||
if _, err := env.hub.DriveInput(route, "hello"); err == nil || !strings.Contains(err.Error(), "正在执行中") {
|
||||
t.Fatalf("DriveInput on running session = %v, want busy rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBridgeReclaimFromDesktopNotifiesController(t *testing.T) {
|
||||
env := newBridgeTestEnvSessions([]bot.DesktopSessionInfo{
|
||||
{TabID: "tab-1", Label: "会话一", Ready: true},
|
||||
})
|
||||
route := testWatchRoute()
|
||||
if _, err := env.hub.Takeover(route, "tab-1"); err != nil {
|
||||
t.Fatalf("Takeover: %v", err)
|
||||
}
|
||||
<-env.announced
|
||||
|
||||
env.hub.reclaimFromDesktop("tab-1")
|
||||
if env.hub.TakeoverTab(route) != "" {
|
||||
t.Fatal("reclaim should clear the binding")
|
||||
}
|
||||
call := env.waitNotification(t)
|
||||
if !strings.Contains(call.msg.Text, "收回") || call.msg.ChatID != route.ChatID {
|
||||
t.Fatalf("reclaim notification = %+v", call)
|
||||
}
|
||||
|
||||
// 未接管 tab 的 reclaim 是 no-op。
|
||||
env.hub.reclaimFromDesktop("tab-1")
|
||||
env.expectNoNotification(t)
|
||||
}
|
||||
@@ -1,982 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/bot/feishu"
|
||||
"reasonix/internal/bot/weixin"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
type BotConnectionCredentialView struct {
|
||||
AppID string `json:"appId"`
|
||||
AppSecretEnv string `json:"appSecretEnv"`
|
||||
AccountID string `json:"accountId"`
|
||||
TokenEnv string `json:"tokenEnv"`
|
||||
SecretSet bool `json:"secretSet"`
|
||||
}
|
||||
|
||||
type BotConnectionSessionMappingView struct {
|
||||
RemoteID string `json:"remoteId"`
|
||||
SessionID string `json:"sessionId"`
|
||||
SessionSource string `json:"sessionSource"`
|
||||
ChatType string `json:"chatType"`
|
||||
UserID string `json:"userId"`
|
||||
ThreadID string `json:"threadId"`
|
||||
Scope string `json:"scope"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type BotConnectionView struct {
|
||||
ID string `json:"id"`
|
||||
Provider string `json:"provider"`
|
||||
Domain string `json:"domain"`
|
||||
Label string `json:"label"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Status string `json:"status"`
|
||||
Model string `json:"model"`
|
||||
ToolApprovalMode string `json:"toolApprovalMode"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
Access BotAccessView `json:"access"`
|
||||
Credential BotConnectionCredentialView `json:"credential"`
|
||||
SessionMappings []BotConnectionSessionMappingView `json:"sessionMappings"`
|
||||
LastError string `json:"lastError"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type BotInstallStartResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Provider string `json:"provider"`
|
||||
Domain string `json:"domain"`
|
||||
InstallID string `json:"installId"`
|
||||
URL string `json:"url"`
|
||||
DeviceCode string `json:"deviceCode"`
|
||||
UserCode string `json:"userCode"`
|
||||
Interval int `json:"interval"`
|
||||
ExpireIn int `json:"expireIn"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type BotInstallPollResult struct {
|
||||
Done bool `json:"done"`
|
||||
Connection BotConnectionView `json:"connection"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type BotConnectionDiagnostic struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
MessageID string `json:"messageId"`
|
||||
Phase string `json:"phase"`
|
||||
Code string `json:"code"`
|
||||
ReportKind string `json:"reportKind"`
|
||||
ReportDetail string `json:"reportDetail"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
type botInstallSession struct {
|
||||
Provider string
|
||||
Domain string
|
||||
PollDomain string
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
StartedAt time.Time
|
||||
ExpireAt time.Time
|
||||
Weixin *weixin.LoginSession
|
||||
}
|
||||
|
||||
func (a *App) StartBotConnectionInstall(provider, domain string) (BotInstallStartResult, error) {
|
||||
provider, domain = normalizeBotInstallTarget(provider, domain)
|
||||
if provider == "weixin" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
session, err := weixin.StartLogin(ctx)
|
||||
if err != nil {
|
||||
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: err.Error()}, nil
|
||||
}
|
||||
installID := randomInstallID()
|
||||
a.mu.Lock()
|
||||
if a.botInstalls == nil {
|
||||
a.botInstalls = map[string]*botInstallSession{}
|
||||
}
|
||||
a.botInstalls[installID] = &botInstallSession{
|
||||
Provider: provider,
|
||||
Domain: domain,
|
||||
DeviceCode: session.QRCode,
|
||||
StartedAt: session.StartedAt,
|
||||
ExpireAt: time.Now().Add(2 * time.Minute),
|
||||
Weixin: session,
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return BotInstallStartResult{
|
||||
OK: true, Provider: provider, Domain: domain, InstallID: installID, URL: firstNonEmptyBot(session.QRCodeURL, session.QRCode),
|
||||
DeviceCode: session.QRCode, Interval: 3, ExpireIn: 120, Message: "请使用微信扫码完成连接。",
|
||||
}, nil
|
||||
}
|
||||
if provider != "feishu" {
|
||||
return BotInstallStartResult{OK: false, Provider: provider, Domain: domain, Message: "unsupported bot provider"}, nil
|
||||
}
|
||||
return a.startFeishuConnectionInstall(domain)
|
||||
}
|
||||
|
||||
func (a *App) PollBotConnectionInstall(installID string) (BotInstallPollResult, error) {
|
||||
installID = strings.TrimSpace(installID)
|
||||
// Copy the session under a.mu: overlapping polls of the same install can
|
||||
// race the locked PollDomain upgrade below with unlocked field reads.
|
||||
a.mu.RLock()
|
||||
sessionPtr := a.botInstalls[installID]
|
||||
var sessionCopy botInstallSession
|
||||
if sessionPtr != nil {
|
||||
sessionCopy = *sessionPtr
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if sessionPtr == nil {
|
||||
return BotInstallPollResult{Error: "install session not found"}, nil
|
||||
}
|
||||
session := &sessionCopy
|
||||
if time.Now().After(session.ExpireAt) {
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "expired", Error: "install session expired"}, nil
|
||||
}
|
||||
if session.Provider == "weixin" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
result, status, err := weixin.PollLogin(ctx, session.Weixin)
|
||||
if err != nil {
|
||||
return BotInstallPollResult{Status: status, Error: err.Error()}, nil
|
||||
}
|
||||
if result == nil {
|
||||
return BotInstallPollResult{Status: status, Message: weixinInstallStatusMessage(status)}, nil
|
||||
}
|
||||
a.deleteBotInstall(installID)
|
||||
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: connectionID("weixin", "weixin"),
|
||||
Provider: "weixin",
|
||||
Domain: "weixin",
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(result.UserID),
|
||||
Credential: config.BotConnectionCredential{AccountID: result.AccountID, TokenEnv: "WEIXIN_BOT_TOKEN"},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
c.Bot.Weixin.Enabled = true
|
||||
c.Bot.Weixin.AccountID = result.AccountID
|
||||
c.Bot.Weixin.APIBase = result.BaseURL
|
||||
if c.Bot.Weixin.TokenEnv == "" {
|
||||
c.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
|
||||
}
|
||||
c.Bot.Allowlist.WeixinUsers = appendUniqueBotString(c.Bot.Allowlist.WeixinUsers, result.UserID)
|
||||
})
|
||||
if err != nil {
|
||||
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
|
||||
}
|
||||
a.refreshBotRuntimeAsync()
|
||||
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: "微信已连接。"}, nil
|
||||
}
|
||||
return a.pollFeishuConnectionInstall(installID, session)
|
||||
}
|
||||
|
||||
func (a *App) DiagnoseBotConnection(id string) (BotConnectionDiagnostic, error) {
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
|
||||
}
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if conn.ID == id {
|
||||
status := "ok"
|
||||
message := "连接配置已保存。"
|
||||
phase := "config"
|
||||
code := "config_ok"
|
||||
reportable := false
|
||||
if !conn.Enabled {
|
||||
status = "disabled"
|
||||
message = "连接已保存但未启用。"
|
||||
code = "connection_disabled"
|
||||
} else if conn.Status != "connected" {
|
||||
status = firstNonEmptyBot(conn.Status, "pending")
|
||||
message = firstNonEmptyBot(conn.LastError, "连接还未完成。")
|
||||
phase = "install"
|
||||
code = "connection_not_connected"
|
||||
reportable = status == "error" || strings.TrimSpace(conn.LastError) != ""
|
||||
} else if conn.Credential.AppSecretEnv != "" && strings.TrimSpace(conn.Credential.AppSecretEnv) != "" && !envIsSet(conn.Credential.AppSecretEnv) {
|
||||
status = "warning"
|
||||
message = conn.Credential.AppSecretEnv + " 未设置。"
|
||||
phase = "credential"
|
||||
code = "secret_missing"
|
||||
reportable = true
|
||||
} else if conn.Credential.TokenEnv != "" && strings.TrimSpace(conn.Credential.TokenEnv) != "" && !botCredentialSecretSet(conn) {
|
||||
status = "warning"
|
||||
message = conn.Credential.TokenEnv + " 未设置,且未找到已保存的登录凭据。"
|
||||
phase = "credential"
|
||||
code = "secret_missing"
|
||||
reportable = true
|
||||
} else if conn.Provider == "weixin" && !botCredentialSecretSet(conn) {
|
||||
status = "warning"
|
||||
message = "未找到已保存的微信登录凭据。"
|
||||
phase = "credential"
|
||||
code = "secret_missing"
|
||||
reportable = true
|
||||
}
|
||||
return botConnectionDiagnostic(&conn, conn.ID, status, phase, code, message, reportable), nil
|
||||
}
|
||||
}
|
||||
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
|
||||
}
|
||||
|
||||
func (a *App) TestBotConnection(id, target string) (BotConnectionDiagnostic, error) {
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
return botConnectionDiagnostic(nil, id, "error", "config", "config_load_failed", err.Error(), true), nil
|
||||
}
|
||||
var conn *config.BotConnectionConfig
|
||||
for i := range cfg.Bot.Connections {
|
||||
if cfg.Bot.Connections[i].ID == strings.TrimSpace(id) {
|
||||
conn = &cfg.Bot.Connections[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if conn == nil {
|
||||
return botConnectionDiagnostic(nil, id, "missing", "config", "connection_missing", "未找到连接。", true), nil
|
||||
}
|
||||
target = firstNonEmptyBot(strings.TrimSpace(target), firstSessionRemoteID(conn.SessionMappings))
|
||||
if conn.Provider != "feishu" && conn.Provider != "weixin" {
|
||||
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_send_unsupported", "当前渠道暂不支持桌面端主动发送测试消息,可使用诊断检查基础配置。", false), nil
|
||||
}
|
||||
if target == "" {
|
||||
return botConnectionDiagnostic(conn, conn.ID, "warning", "send", "test_target_missing", "请输入测试会话 ID 后再发送测试消息。", false), nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
var result bot.SendResult
|
||||
switch conn.Provider {
|
||||
case "feishu":
|
||||
feishuCfg := cfg.Bot.Feishu
|
||||
feishuCfg.Enabled = true
|
||||
feishuCfg.Domain = firstNonEmptyBot(conn.Domain, feishuCfg.Domain)
|
||||
feishuCfg.AppID = firstNonEmptyBot(conn.Credential.AppID, feishuCfg.AppID)
|
||||
feishuCfg.AppSecretEnv = firstNonEmptyBot(conn.Credential.AppSecretEnv, feishuCfg.AppSecretEnv)
|
||||
result, err = feishu.SendText(ctx, feishuCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
|
||||
case "weixin":
|
||||
weixinCfg := cfg.Bot.Weixin
|
||||
weixinCfg.Enabled = true
|
||||
weixinCfg.AccountID = firstNonEmptyBot(conn.Credential.AccountID, weixinCfg.AccountID)
|
||||
weixinCfg.TokenEnv = firstNonEmptyBot(conn.Credential.TokenEnv, weixinCfg.TokenEnv)
|
||||
result, err = weixin.SendText(ctx, weixinCfg, target, "Reasonix bot 测试消息:连接和发送链路可用。")
|
||||
}
|
||||
if err != nil {
|
||||
return botConnectionDiagnostic(conn, conn.ID, "error", "send", "test_send_failed", err.Error(), true), nil
|
||||
}
|
||||
_ = a.rememberBotConnectionRemote(conn.ID, target)
|
||||
msg := "测试消息已发送。"
|
||||
if result.MessageID != "" {
|
||||
msg += " Message ID: " + result.MessageID
|
||||
}
|
||||
diag := botConnectionDiagnostic(conn, conn.ID, "ok", "send", "test_send_ok", msg, false)
|
||||
diag.MessageID = result.MessageID
|
||||
return diag, nil
|
||||
}
|
||||
|
||||
func botConnectionDiagnostic(conn *config.BotConnectionConfig, id, status, phase, code, message string, reportable bool) BotConnectionDiagnostic {
|
||||
id = strings.TrimSpace(id)
|
||||
label := ""
|
||||
if conn != nil {
|
||||
id = firstNonEmptyBot(strings.TrimSpace(conn.ID), id)
|
||||
label = strings.TrimSpace(conn.Label)
|
||||
}
|
||||
occurredAt := time.Now().UTC().Format(time.RFC3339)
|
||||
diag := BotConnectionDiagnostic{
|
||||
ID: id,
|
||||
Label: label,
|
||||
Status: strings.TrimSpace(status),
|
||||
Message: strings.TrimSpace(message),
|
||||
Phase: strings.TrimSpace(phase),
|
||||
Code: strings.TrimSpace(code),
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
if reportable {
|
||||
diag.ReportKind = "bot"
|
||||
diag.ReportDetail = botConnectionReportDetail(conn, id, diag.Status, diag.Phase, diag.Code, diag.Message, occurredAt)
|
||||
if diag.ReportDetail == "" {
|
||||
diag.ReportKind = ""
|
||||
}
|
||||
}
|
||||
return diag
|
||||
}
|
||||
|
||||
func botConnectionReportDetail(conn *config.BotConnectionConfig, fallbackID, status, phase, code, message, occurredAt string) string {
|
||||
provider := "unknown"
|
||||
domain := "unknown"
|
||||
configuredStatus := ""
|
||||
enabled := false
|
||||
workspaceScope := "global"
|
||||
sessionMappings := 0
|
||||
appIDSet := false
|
||||
appSecretEnvConfigured := false
|
||||
tokenEnvConfigured := false
|
||||
secretAvailable := false
|
||||
if conn != nil {
|
||||
provider = firstNonEmptyBot(strings.TrimSpace(conn.Provider), provider)
|
||||
domain = firstNonEmptyBot(strings.TrimSpace(conn.Domain), domain)
|
||||
configuredStatus = strings.TrimSpace(conn.Status)
|
||||
enabled = conn.Enabled
|
||||
if strings.TrimSpace(conn.WorkspaceRoot) != "" {
|
||||
workspaceScope = "project"
|
||||
}
|
||||
sessionMappings = len(conn.SessionMappings)
|
||||
appIDSet = strings.TrimSpace(conn.Credential.AppID) != ""
|
||||
appSecretEnvConfigured = strings.TrimSpace(conn.Credential.AppSecretEnv) != ""
|
||||
tokenEnvConfigured = strings.TrimSpace(conn.Credential.TokenEnv) != ""
|
||||
secretAvailable = botCredentialSecretSet(*conn)
|
||||
}
|
||||
summary := botConnectionReportSummary(code, message)
|
||||
lines := []string{
|
||||
"Bot connection diagnostic",
|
||||
"",
|
||||
"connection_id: " + safeBotReportValue(fallbackID),
|
||||
"provider: " + safeBotReportValue(provider),
|
||||
"domain: " + safeBotReportValue(domain),
|
||||
"status: " + safeBotReportValue(status),
|
||||
"phase: " + safeBotReportValue(phase),
|
||||
"code: " + safeBotReportValue(code),
|
||||
fmt.Sprintf("enabled: %t", enabled),
|
||||
"configured_status: " + safeBotReportValue(configuredStatus),
|
||||
fmt.Sprintf("app_id_set: %t", appIDSet),
|
||||
fmt.Sprintf("app_secret_env_configured: %t", appSecretEnvConfigured),
|
||||
fmt.Sprintf("token_env_configured: %t", tokenEnvConfigured),
|
||||
fmt.Sprintf("secret_available: %t", secretAvailable),
|
||||
"workspace_scope: " + workspaceScope,
|
||||
fmt.Sprintf("session_mappings: %d", sessionMappings),
|
||||
"",
|
||||
"summary: " + summary,
|
||||
}
|
||||
payload := frontendCrashPayload{
|
||||
SchemaVersion: 2,
|
||||
Kind: "bot",
|
||||
Source: "bot.runtime",
|
||||
Label: botConnectionReportLabel(provider, domain, phase),
|
||||
Message: strings.Join(lines, "\n"),
|
||||
ErrorType: "BotConnectionDiagnostic",
|
||||
ErrorMessage: summary,
|
||||
TopFrame: "bot." + safeBotReportSegment(phase),
|
||||
OccurredAt: occurredAt,
|
||||
}
|
||||
detail, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(detail)
|
||||
}
|
||||
|
||||
func botConnectionReportSummary(code, message string) string {
|
||||
switch strings.TrimSpace(code) {
|
||||
case "config_load_failed":
|
||||
return "desktop bot config could not be loaded: " + scrubSensitiveText(message)
|
||||
case "connection_missing":
|
||||
return "bot connection record was not found"
|
||||
case "connection_not_connected":
|
||||
return "bot connection is not connected: " + scrubSensitiveText(message)
|
||||
case "secret_missing":
|
||||
return "required bot credential is not available"
|
||||
case "test_send_failed":
|
||||
return "bot test message failed: " + scrubSensitiveText(message)
|
||||
default:
|
||||
if strings.TrimSpace(message) == "" {
|
||||
return strings.TrimSpace(code)
|
||||
}
|
||||
return scrubSensitiveText(message)
|
||||
}
|
||||
}
|
||||
|
||||
func botConnectionReportLabel(provider, domain, phase string) string {
|
||||
parts := []string{"bot", safeBotReportSegment(provider), safeBotReportSegment(domain), safeBotReportSegment(phase)}
|
||||
return strings.Trim(strings.Join(parts, "."), ".")
|
||||
}
|
||||
|
||||
func safeBotReportSegment(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return "unknown"
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '_' || r == '-' {
|
||||
b.WriteRune(r)
|
||||
continue
|
||||
}
|
||||
if b.Len() == 0 || strings.HasSuffix(b.String(), ".") {
|
||||
continue
|
||||
}
|
||||
b.WriteByte('.')
|
||||
}
|
||||
out := strings.Trim(b.String(), ".")
|
||||
if out == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func safeBotReportValue(s string) string {
|
||||
s = safeBotReportSegment(s)
|
||||
if len(s) > 80 {
|
||||
return s[:80]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (a *App) startFeishuConnectionInstall(domain string) (BotInstallStartResult, error) {
|
||||
// The official registration SDK always begins on the Feishu accounts domain.
|
||||
// Lark tenants are detected from the first poll response, then polling moves
|
||||
// to the Lark accounts domain for the final credential exchange.
|
||||
beginDomain := "feishu"
|
||||
data, err := postFeishuInstallForm(feishuAccountsBase(beginDomain), map[string]string{
|
||||
"action": "begin", "archetype": "PersonalAgent", "auth_method": "client_secret", "request_user_info": "open_id",
|
||||
})
|
||||
if err != nil {
|
||||
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
|
||||
}
|
||||
deviceCode := stringValue(data["device_code"])
|
||||
verifyURL := stringValue(data["verification_uri_complete"])
|
||||
userCode := stringValue(data["user_code"])
|
||||
if deviceCode == "" || verifyURL == "" {
|
||||
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: "飞书/Lark 授权响应缺少 device_code 或二维码 URL。"}, nil
|
||||
}
|
||||
qrURL, err := feishuRegistrationQRCodeURL(verifyURL)
|
||||
if err != nil {
|
||||
return BotInstallStartResult{OK: false, Provider: "feishu", Domain: domain, Message: err.Error()}, nil
|
||||
}
|
||||
installID := randomInstallID()
|
||||
interval := intValue(data["interval"], 5)
|
||||
expireIn := intValue(firstAny(data["expire_in"], data["expires_in"]), 300)
|
||||
a.mu.Lock()
|
||||
if a.botInstalls == nil {
|
||||
a.botInstalls = map[string]*botInstallSession{}
|
||||
}
|
||||
a.botInstalls[installID] = &botInstallSession{
|
||||
Provider: "feishu", Domain: domain, PollDomain: beginDomain, DeviceCode: deviceCode, UserCode: userCode,
|
||||
StartedAt: time.Now(), ExpireAt: time.Now().Add(time.Duration(expireIn) * time.Second),
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return BotInstallStartResult{OK: true, Provider: "feishu", Domain: domain, InstallID: installID, URL: qrURL, DeviceCode: deviceCode, UserCode: userCode, Interval: interval, ExpireIn: expireIn}, nil
|
||||
}
|
||||
|
||||
func (a *App) pollFeishuConnectionInstall(installID string, session *botInstallSession) (BotInstallPollResult, error) {
|
||||
pollDomain := firstNonEmptyBot(session.PollDomain, session.Domain, "feishu")
|
||||
data, statusCode, err := postFeishuInstallFormResult(feishuAccountsBase(pollDomain), map[string]string{"action": "poll", "device_code": session.DeviceCode})
|
||||
if err != nil {
|
||||
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
|
||||
}
|
||||
if errText := stringValue(data["error"]); errText != "" {
|
||||
if errText == "authorization_pending" || errText == "slow_down" {
|
||||
return BotInstallPollResult{Status: "pending", Message: "等待扫码授权。"}, nil
|
||||
}
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "error", Error: firstNonEmptyBot(stringValue(data["error_description"]), errText)}, nil
|
||||
}
|
||||
if statusCode >= 400 {
|
||||
a.deleteBotInstall(installID)
|
||||
return BotInstallPollResult{Status: "error", Error: fmt.Sprintf("HTTP %d", statusCode)}, nil
|
||||
}
|
||||
if feishuInstallDomain(session.Domain, data) == "lark" && pollDomain != "lark" {
|
||||
a.mu.Lock()
|
||||
if current := a.botInstalls[installID]; current != nil {
|
||||
current.PollDomain = "lark"
|
||||
}
|
||||
a.mu.Unlock()
|
||||
return BotInstallPollResult{Status: "pending", Message: "已识别为 Lark 授权,继续等待授权完成。"}, nil
|
||||
}
|
||||
appID := stringValue(data["client_id"])
|
||||
appSecret := stringValue(data["client_secret"])
|
||||
if appID == "" || appSecret == "" {
|
||||
return BotInstallPollResult{Status: "pending", Message: "等待授权完成。"}, nil
|
||||
}
|
||||
a.deleteBotInstall(installID)
|
||||
domain := feishuInstallDomain(firstNonEmptyBot(pollDomain, session.Domain), data)
|
||||
userID := feishuInstallUserID(data)
|
||||
secretEnv := "FEISHU_BOT_APP_SECRET"
|
||||
if domain == "lark" {
|
||||
secretEnv = "LARK_BOT_APP_SECRET"
|
||||
}
|
||||
if err := upsertDotEnv(secretEnv, appSecret); err != nil {
|
||||
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
|
||||
}
|
||||
label := "飞书"
|
||||
if domain == "lark" {
|
||||
label = "Lark"
|
||||
}
|
||||
conn, err := a.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: connectionID("feishu", domain),
|
||||
Provider: "feishu",
|
||||
Domain: domain,
|
||||
Label: label,
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Access: botInstallAccess(userID),
|
||||
Credential: config.BotConnectionCredential{AppID: appID, AppSecretEnv: secretEnv},
|
||||
}, func(c *config.Config) {
|
||||
c.Bot.Enabled = true
|
||||
c.Bot.Feishu.Enabled = true
|
||||
c.Bot.Feishu.Domain = domain
|
||||
c.Bot.Feishu.AppID = appID
|
||||
c.Bot.Feishu.AppSecretEnv = secretEnv
|
||||
c.Bot.Feishu.Mode = "websocket"
|
||||
c.Bot.Feishu.RequireMention = true
|
||||
c.Bot.Allowlist.FeishuUsers = appendUniqueBotString(c.Bot.Allowlist.FeishuUsers, userID)
|
||||
})
|
||||
if err != nil {
|
||||
return BotInstallPollResult{Status: "error", Error: err.Error()}, nil
|
||||
}
|
||||
a.refreshBotRuntimeAsync()
|
||||
return BotInstallPollResult{Done: true, Status: "connected", Connection: conn, Message: label + " 已连接。"}, nil
|
||||
}
|
||||
|
||||
func (a *App) upsertBotConnection(conn config.BotConnectionConfig, updateLegacy func(*config.Config)) (BotConnectionView, error) {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if conn.CreatedAt == "" {
|
||||
conn.CreatedAt = now
|
||||
}
|
||||
conn.UpdatedAt = now
|
||||
if conn.Status == "" {
|
||||
conn.Status = "connected"
|
||||
}
|
||||
if normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode) == "" {
|
||||
conn.ToolApprovalMode = "ask"
|
||||
}
|
||||
if conn.ID == "" {
|
||||
conn.ID = connectionID(conn.Provider, conn.Domain)
|
||||
}
|
||||
err := a.applyConfigOnly(func(c *config.Config) error {
|
||||
if updateLegacy != nil {
|
||||
updateLegacy(c)
|
||||
}
|
||||
replaced := false
|
||||
for i, existing := range c.Bot.Connections {
|
||||
if existing.ID == conn.ID {
|
||||
conn.CreatedAt = firstNonEmptyBot(existing.CreatedAt, conn.CreatedAt)
|
||||
if !botruntime.BotAccessActive(conn.Access) && botruntime.BotAccessActive(existing.Access) {
|
||||
conn.Access = existing.Access
|
||||
}
|
||||
c.Bot.Connections[i] = conn
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
c.Bot.Connections = append(c.Bot.Connections, conn)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return botConnectionView(conn), err
|
||||
}
|
||||
|
||||
func (a *App) rememberBotConnectionRemote(id, remoteID string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
remoteID = strings.TrimSpace(remoteID)
|
||||
if id == "" || remoteID == "" {
|
||||
return nil
|
||||
}
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
return a.applyConfigOnly(func(c *config.Config) error {
|
||||
for i := range c.Bot.Connections {
|
||||
if c.Bot.Connections[i].ID != id {
|
||||
continue
|
||||
}
|
||||
for j := range c.Bot.Connections[i].SessionMappings {
|
||||
if c.Bot.Connections[i].SessionMappings[j].RemoteID == remoteID {
|
||||
workspaceRoot := firstNonEmptyBot(c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot, c.Bot.Connections[i].WorkspaceRoot)
|
||||
scope := botMappingScope(c.Bot.Connections[i].SessionMappings[j].Scope, workspaceRoot)
|
||||
c.Bot.Connections[i].SessionMappings[j].Scope = scope
|
||||
c.Bot.Connections[i].SessionMappings[j].WorkspaceRoot = botMappingWorkspaceRoot(scope, workspaceRoot)
|
||||
c.Bot.Connections[i].SessionMappings[j].UpdatedAt = now
|
||||
c.Bot.Connections[i].UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
}
|
||||
scope := botMappingScope("", c.Bot.Connections[i].WorkspaceRoot)
|
||||
c.Bot.Connections[i].SessionMappings = append(c.Bot.Connections[i].SessionMappings, config.BotConnectionSessionMapping{
|
||||
RemoteID: remoteID,
|
||||
SessionID: "",
|
||||
Scope: scope,
|
||||
WorkspaceRoot: botMappingWorkspaceRoot(scope, c.Bot.Connections[i].WorkspaceRoot),
|
||||
UpdatedAt: now,
|
||||
})
|
||||
c.Bot.Connections[i].UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func firstSessionRemoteID(mappings []config.BotConnectionSessionMapping) string {
|
||||
for _, mapping := range mappings {
|
||||
if strings.TrimSpace(mapping.RemoteID) != "" {
|
||||
return strings.TrimSpace(mapping.RemoteID)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (a *App) deleteBotInstall(installID string) {
|
||||
a.mu.Lock()
|
||||
delete(a.botInstalls, installID)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func normalizeBotInstallTarget(provider, domain string) (string, string) {
|
||||
provider = strings.ToLower(strings.TrimSpace(provider))
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
if provider == "lark" {
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
}
|
||||
if provider == "weixin" || provider == "wechat" {
|
||||
return "weixin", "weixin"
|
||||
}
|
||||
if domain != "lark" {
|
||||
domain = "feishu"
|
||||
}
|
||||
return "feishu", domain
|
||||
}
|
||||
|
||||
func feishuAccountsBase(domain string) string {
|
||||
if domain == "lark" {
|
||||
return "https://accounts.larksuite.com"
|
||||
}
|
||||
return "https://accounts.feishu.cn"
|
||||
}
|
||||
|
||||
func feishuRegistrationQRCodeURL(rawURL string) (string, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
query := parsedURL.Query()
|
||||
query.Set("from", "sdk")
|
||||
query.Set("tp", "sdk")
|
||||
query.Set("source", "go-sdk")
|
||||
parsedURL.RawQuery = query.Encode()
|
||||
return parsedURL.String(), nil
|
||||
}
|
||||
|
||||
func postFeishuInstallForm(base string, body map[string]string) (map[string]any, error) {
|
||||
data, status, err := postFeishuInstallFormResult(base, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status >= 400 {
|
||||
return nil, fmt.Errorf("HTTP %d: %s", status, firstNonEmptyBot(stringValue(data["error_description"]), stringValue(data["message"])))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func postFeishuInstallFormResult(base string, body map[string]string) (map[string]any, int, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
reqBody := url.Values{}
|
||||
for k, v := range body {
|
||||
reqBody.Set(k, v)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(base, "/")+"/oauth/v1/app/registration", strings.NewReader(reqBody.Encode()))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
return out, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
func botConnectionView(conn config.BotConnectionConfig) BotConnectionView {
|
||||
return BotConnectionView{
|
||||
ID: conn.ID, Provider: conn.Provider, Domain: conn.Domain, Label: conn.Label, Enabled: conn.Enabled, Status: conn.Status,
|
||||
Model: conn.Model, ToolApprovalMode: normalizeBotConnectionToolApprovalMode(conn.ToolApprovalMode), WorkspaceRoot: conn.WorkspaceRoot,
|
||||
Access: botAccessViewFromConfig(conn.Access),
|
||||
Credential: BotConnectionCredentialView{
|
||||
AppID: conn.Credential.AppID, AppSecretEnv: conn.Credential.AppSecretEnv, AccountID: conn.Credential.AccountID, TokenEnv: conn.Credential.TokenEnv,
|
||||
SecretSet: botCredentialSecretSet(conn),
|
||||
},
|
||||
SessionMappings: botSessionMappingViews(conn.SessionMappings, conn.WorkspaceRoot),
|
||||
LastError: conn.LastError, CreatedAt: conn.CreatedAt, UpdatedAt: conn.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func botCredentialSecretSet(conn config.BotConnectionConfig) bool {
|
||||
if conn.Credential.AppSecretEnv != "" {
|
||||
return envIsSet(conn.Credential.AppSecretEnv)
|
||||
}
|
||||
if conn.Credential.TokenEnv != "" && envIsSet(conn.Credential.TokenEnv) {
|
||||
return true
|
||||
}
|
||||
if conn.Provider == "weixin" {
|
||||
return weixin.HasSavedAccount(conn.Credential.AccountID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func feishuInstallDomain(fallback string, data map[string]any) string {
|
||||
if userInfo, ok := data["user_info"].(map[string]any); ok {
|
||||
if strings.EqualFold(stringValue(userInfo["tenant_brand"]), "lark") {
|
||||
return "lark"
|
||||
}
|
||||
return "feishu"
|
||||
}
|
||||
if strings.EqualFold(fallback, "lark") {
|
||||
return "lark"
|
||||
}
|
||||
return "feishu"
|
||||
}
|
||||
|
||||
func feishuInstallUserID(data map[string]any) string {
|
||||
if userInfo, ok := data["user_info"].(map[string]any); ok {
|
||||
return firstNonEmptyBot(
|
||||
stringValue(userInfo["open_id"]),
|
||||
stringValue(userInfo["union_id"]),
|
||||
stringValue(userInfo["user_id"]),
|
||||
)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func botConnectionViews(connections []config.BotConnectionConfig) []BotConnectionView {
|
||||
if connections == nil {
|
||||
return []BotConnectionView{}
|
||||
}
|
||||
out := make([]BotConnectionView, 0, len(connections))
|
||||
for _, conn := range connections {
|
||||
out = append(out, botConnectionView(conn))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botConnectionConfig(view BotConnectionView) config.BotConnectionConfig {
|
||||
return config.BotConnectionConfig{
|
||||
ID: strings.TrimSpace(view.ID),
|
||||
Provider: strings.TrimSpace(view.Provider),
|
||||
Domain: strings.TrimSpace(view.Domain),
|
||||
Label: strings.TrimSpace(view.Label),
|
||||
Enabled: view.Enabled,
|
||||
Status: strings.TrimSpace(view.Status),
|
||||
Model: strings.TrimSpace(view.Model),
|
||||
ToolApprovalMode: firstNonEmptyBot(normalizeBotConnectionToolApprovalMode(view.ToolApprovalMode), "ask"),
|
||||
WorkspaceRoot: strings.TrimSpace(view.WorkspaceRoot),
|
||||
Access: botAccessConfigFromView(view.Access),
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: strings.TrimSpace(view.Credential.AppID),
|
||||
AppSecretEnv: strings.TrimSpace(view.Credential.AppSecretEnv),
|
||||
AccountID: strings.TrimSpace(view.Credential.AccountID),
|
||||
TokenEnv: strings.TrimSpace(view.Credential.TokenEnv),
|
||||
},
|
||||
SessionMappings: botSessionMappingConfigs(view.SessionMappings, view.WorkspaceRoot),
|
||||
LastError: strings.TrimSpace(view.LastError),
|
||||
CreatedAt: strings.TrimSpace(view.CreatedAt),
|
||||
UpdatedAt: strings.TrimSpace(view.UpdatedAt),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeBotConnectionToolApprovalMode(mode string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "ask":
|
||||
return "ask"
|
||||
case "auto":
|
||||
return "auto"
|
||||
case "yolo", "full", "full-access", "bypass":
|
||||
return "yolo"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func botConnectionConfigs(views []BotConnectionView) []config.BotConnectionConfig {
|
||||
if views == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]config.BotConnectionConfig, 0, len(views))
|
||||
for _, view := range views {
|
||||
cfg := botConnectionConfig(view)
|
||||
if cfg.ID == "" || cfg.Provider == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, cfg)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botMappingScope(scope, workspaceRoot string) string {
|
||||
if strings.TrimSpace(scope) == "project" {
|
||||
return "project"
|
||||
}
|
||||
if strings.TrimSpace(workspaceRoot) != "" {
|
||||
return "project"
|
||||
}
|
||||
return "global"
|
||||
}
|
||||
|
||||
func botMappingWorkspaceRoot(scope, workspaceRoot string) string {
|
||||
if botMappingScope(scope, workspaceRoot) != "project" {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(workspaceRoot)
|
||||
}
|
||||
|
||||
func botSessionMappingViews(mappings []config.BotConnectionSessionMapping, connectionWorkspaceRoot string) []BotConnectionSessionMappingView {
|
||||
if mappings == nil {
|
||||
return []BotConnectionSessionMappingView{}
|
||||
}
|
||||
out := make([]BotConnectionSessionMappingView, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
|
||||
scope := botMappingScope(m.Scope, workspaceRoot)
|
||||
out = append(out, BotConnectionSessionMappingView{
|
||||
RemoteID: m.RemoteID,
|
||||
SessionID: m.SessionID,
|
||||
SessionSource: m.SessionSource,
|
||||
ChatType: m.ChatType,
|
||||
UserID: m.UserID,
|
||||
ThreadID: m.ThreadID,
|
||||
Scope: scope,
|
||||
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func botSessionMappingConfigs(mappings []BotConnectionSessionMappingView, connectionWorkspaceRoot string) []config.BotConnectionSessionMapping {
|
||||
if mappings == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]config.BotConnectionSessionMapping, 0, len(mappings))
|
||||
for _, m := range mappings {
|
||||
workspaceRoot := firstNonEmptyBot(m.WorkspaceRoot, connectionWorkspaceRoot)
|
||||
scope := botMappingScope(m.Scope, workspaceRoot)
|
||||
out = append(out, config.BotConnectionSessionMapping{
|
||||
RemoteID: strings.TrimSpace(m.RemoteID),
|
||||
SessionID: strings.TrimSpace(m.SessionID),
|
||||
SessionSource: strings.TrimSpace(m.SessionSource),
|
||||
ChatType: strings.TrimSpace(m.ChatType),
|
||||
UserID: strings.TrimSpace(m.UserID),
|
||||
ThreadID: strings.TrimSpace(m.ThreadID),
|
||||
Scope: scope,
|
||||
WorkspaceRoot: botMappingWorkspaceRoot(scope, workspaceRoot),
|
||||
UpdatedAt: strings.TrimSpace(m.UpdatedAt),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func connectionID(provider, domain string) string {
|
||||
return strings.Trim(strings.ToLower(provider+"-"+domain), "-")
|
||||
}
|
||||
|
||||
func botInstallAccess(userID string) config.BotAccessConfig {
|
||||
userID = strings.TrimSpace(userID)
|
||||
access := config.BotAccessConfig{Enabled: true, PairingEnabled: true}
|
||||
if userID != "" {
|
||||
access.Users = []string{userID}
|
||||
}
|
||||
return access
|
||||
}
|
||||
|
||||
func randomInstallID() string {
|
||||
var b [12]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("install-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func envIsSet(name string) bool {
|
||||
return strings.TrimSpace(name) != "" && strings.TrimSpace(os.Getenv(name)) != ""
|
||||
}
|
||||
|
||||
func firstAny(values ...any) any {
|
||||
for _, value := range values {
|
||||
if value != nil {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstNonEmptyBot(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func appendUniqueBotString(values []string, next string) []string {
|
||||
next = strings.TrimSpace(next)
|
||||
if next == "" {
|
||||
return values
|
||||
}
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) == next {
|
||||
return values
|
||||
}
|
||||
}
|
||||
return append(values, next)
|
||||
}
|
||||
|
||||
func stringValue(value any) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func intValue(value any, fallback int) int {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
if v > 0 {
|
||||
return int(v)
|
||||
}
|
||||
case int:
|
||||
if v > 0 {
|
||||
return v
|
||||
}
|
||||
case string:
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n > 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func weixinInstallStatusMessage(status string) string {
|
||||
switch status {
|
||||
case "scaned":
|
||||
return "已扫码,请在微信里确认。"
|
||||
case "scaned_but_redirect":
|
||||
return "已扫码,正在切换微信授权节点。"
|
||||
default:
|
||||
return "等待扫码。"
|
||||
}
|
||||
}
|
||||
@@ -1,620 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestNormalizeBotInstallTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
provider string
|
||||
domain string
|
||||
wantProvider string
|
||||
wantDomain string
|
||||
}{
|
||||
{provider: "lark", wantProvider: "feishu", wantDomain: "lark"},
|
||||
{provider: "feishu", domain: "lark", wantProvider: "feishu", wantDomain: "lark"},
|
||||
{provider: "wechat", wantProvider: "weixin", wantDomain: "weixin"},
|
||||
{provider: "weixin", domain: "anything", wantProvider: "weixin", wantDomain: "weixin"},
|
||||
{provider: "unknown", domain: "unknown", wantProvider: "feishu", wantDomain: "feishu"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
gotProvider, gotDomain := normalizeBotInstallTarget(tc.provider, tc.domain)
|
||||
if gotProvider != tc.wantProvider || gotDomain != tc.wantDomain {
|
||||
t.Fatalf("normalizeBotInstallTarget(%q,%q) = %q,%q; want %q,%q", tc.provider, tc.domain, gotProvider, gotDomain, tc.wantProvider, tc.wantDomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLarkInstallFollowsSDKDomainSwitchAndStoresSecret(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
|
||||
pollCount := 0
|
||||
var beginHost string
|
||||
var pollHosts []string
|
||||
var actions []string
|
||||
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/v1/app/registration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.Form.Get("action") {
|
||||
case "begin":
|
||||
beginHost = r.Header.Get("X-Test-Original-Host")
|
||||
actions = append(actions, "begin")
|
||||
if r.Form.Get("archetype") != "PersonalAgent" || r.Form.Get("auth_method") != "client_secret" {
|
||||
http.Error(w, "wrong begin form", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"device_code": "dev-lark",
|
||||
"verification_uri_complete": "https://open.feishu.cn/page/launcher?user_code=CODE",
|
||||
"user_code": "CODE",
|
||||
"interval": 3,
|
||||
"expire_in": 300,
|
||||
})
|
||||
case "poll":
|
||||
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
|
||||
actions = append(actions, "poll")
|
||||
if r.Form.Get("device_code") != "dev-lark" {
|
||||
http.Error(w, "wrong device code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollCount++
|
||||
if pollCount == 1 {
|
||||
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"client_id": "cli-1",
|
||||
"client_secret": "secret-1",
|
||||
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-installer"},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
|
||||
app := NewApp()
|
||||
start, err := app.StartBotConnectionInstall("lark", "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !start.OK || start.Domain != "lark" || start.InstallID == "" || start.URL == "" || start.DeviceCode != "dev-lark" {
|
||||
t.Fatalf("start result = %+v, want ok lark-capable QR result", start)
|
||||
}
|
||||
qrURL, err := url.Parse(start.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("start URL = %q, want valid QR URL: %v", start.URL, err)
|
||||
}
|
||||
query := qrURL.Query()
|
||||
if query.Get("user_code") != "CODE" || query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
|
||||
t.Fatalf("start URL query = %v, want SDK registration QR metadata with user_code", query)
|
||||
}
|
||||
if qrURL.Host != "open.feishu.cn" {
|
||||
t.Fatalf("start URL host = %q, want SDK Feishu launcher host", qrURL.Host)
|
||||
}
|
||||
|
||||
pending, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall pending: %v", err)
|
||||
}
|
||||
if pending.Done || pending.Status != "pending" {
|
||||
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
|
||||
}
|
||||
poll, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !poll.Done {
|
||||
t.Fatalf("poll result = %+v, want done", poll)
|
||||
}
|
||||
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "lark" || poll.Connection.ID != "feishu-lark" {
|
||||
t.Fatalf("connection = %+v, want feishu-lark from tenant_brand", poll.Connection)
|
||||
}
|
||||
if beginHost != "accounts.feishu.cn" {
|
||||
t.Fatalf("begin host = %q, want SDK Feishu accounts host", beginHost)
|
||||
}
|
||||
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
|
||||
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
|
||||
}
|
||||
if got := strings.Join(actions, ","); got != "begin,poll,poll" {
|
||||
t.Fatalf("registration actions = %q, want SDK begin, domain switch, final poll", got)
|
||||
}
|
||||
if poll.Connection.WorkspaceRoot != "" {
|
||||
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
|
||||
}
|
||||
if poll.Connection.Credential.AppID != "cli-1" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
|
||||
t.Fatalf("credential = %+v, want stored Lark secret", poll.Connection.Credential)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "lark" || cfg.Bot.Feishu.Mode != "websocket" || !cfg.Bot.Feishu.RequireMention {
|
||||
t.Fatalf("saved feishu config = %+v, want enabled websocket lark with mention gating", cfg.Bot.Feishu)
|
||||
}
|
||||
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-installer" {
|
||||
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
|
||||
}
|
||||
if err := os.Unsetenv("LARK_BOT_APP_SECRET"); err != nil {
|
||||
t.Fatalf("unset lark secret env: %v", err)
|
||||
}
|
||||
reloaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if got := os.Getenv("LARK_BOT_APP_SECRET"); got != "secret-1" {
|
||||
t.Fatalf("reloaded LARK_BOT_APP_SECRET = %q, want persisted secret", got)
|
||||
}
|
||||
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
|
||||
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuInstallSwitchesToLarkDomainWhenTenantBrandIsLark(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Cleanup(func() { _ = os.Unsetenv("LARK_BOT_APP_SECRET") })
|
||||
pollCount := 0
|
||||
var beginHost string
|
||||
var pollHosts []string
|
||||
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/v1/app/registration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
switch r.Form.Get("action") {
|
||||
case "begin":
|
||||
beginHost = r.Header.Get("X-Test-Original-Host")
|
||||
writeJSON(t, w, map[string]any{
|
||||
"device_code": "dev-feishu",
|
||||
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
|
||||
"user_code": "CODE",
|
||||
"interval": 3,
|
||||
"expire_in": 300,
|
||||
})
|
||||
case "poll":
|
||||
pollHosts = append(pollHosts, r.Header.Get("X-Test-Original-Host"))
|
||||
if r.Form.Get("device_code") != "dev-feishu" {
|
||||
http.Error(w, "wrong device code", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
pollCount++
|
||||
if pollCount == 1 {
|
||||
writeJSON(t, w, map[string]any{"user_info": map[string]any{"tenant_brand": "lark"}})
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"client_id": "cli-lark",
|
||||
"client_secret": "secret-lark",
|
||||
"user_info": map[string]any{"tenant_brand": "lark", "open_id": "ou-lark-installer"},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
|
||||
app := NewApp()
|
||||
start, err := app.StartBotConnectionInstall("feishu", "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !start.OK || start.Domain != "feishu" || start.DeviceCode != "dev-feishu" {
|
||||
t.Fatalf("start result = %+v, want Feishu QR result", start)
|
||||
}
|
||||
pending, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall pending: %v", err)
|
||||
}
|
||||
if pending.Done || pending.Status != "pending" {
|
||||
t.Fatalf("pending poll result = %+v, want pending domain switch", pending)
|
||||
}
|
||||
poll, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !poll.Done || poll.Connection.Domain != "lark" || poll.Connection.Credential.AppSecretEnv != "LARK_BOT_APP_SECRET" {
|
||||
t.Fatalf("poll result = %+v, want stored Lark connection after domain switch", poll)
|
||||
}
|
||||
if beginHost != "accounts.feishu.cn" {
|
||||
t.Fatalf("begin host = %q, want Feishu accounts host", beginHost)
|
||||
}
|
||||
if got := strings.Join(pollHosts, ","); got != "accounts.feishu.cn,accounts.larksuite.com" {
|
||||
t.Fatalf("poll hosts = %q, want Feishu poll then Lark poll", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuInstallStoresFeishuSecretAndSurvivesReload(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Cleanup(func() { _ = os.Unsetenv("FEISHU_BOT_APP_SECRET") })
|
||||
var hosts []string
|
||||
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/oauth/v1/app/registration" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hosts = append(hosts, r.Form.Get("action")+":"+r.Header.Get("X-Test-Original-Host"))
|
||||
switch r.Form.Get("action") {
|
||||
case "begin":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"device_code": "dev-feishu",
|
||||
"verification_uri_complete": "https://accounts.example/verify?user_code=CODE",
|
||||
"user_code": "CODE",
|
||||
"interval": 3,
|
||||
"expire_in": 300,
|
||||
})
|
||||
case "poll":
|
||||
writeJSON(t, w, map[string]any{
|
||||
"client_id": "cli-feishu",
|
||||
"client_secret": "secret-feishu",
|
||||
"user_info": map[string]any{"tenant_brand": "feishu", "open_id": "ou-feishu-installer"},
|
||||
})
|
||||
default:
|
||||
http.Error(w, "unknown action", http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
|
||||
app := NewApp()
|
||||
start, err := app.StartBotConnectionInstall("feishu", "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !start.OK || start.Domain != "feishu" || start.InstallID == "" {
|
||||
t.Fatalf("start result = %+v, want ok Feishu QR result", start)
|
||||
}
|
||||
poll, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !poll.Done {
|
||||
t.Fatalf("poll result = %+v, want done", poll)
|
||||
}
|
||||
if poll.Connection.Provider != "feishu" || poll.Connection.Domain != "feishu" || poll.Connection.ID != "feishu-feishu" {
|
||||
t.Fatalf("connection = %+v, want feishu-feishu", poll.Connection)
|
||||
}
|
||||
if poll.Connection.Credential.AppID != "cli-feishu" || poll.Connection.Credential.AppSecretEnv != "FEISHU_BOT_APP_SECRET" || !poll.Connection.Credential.SecretSet {
|
||||
t.Fatalf("credential = %+v, want stored Feishu secret", poll.Connection.Credential)
|
||||
}
|
||||
if got := strings.Join(hosts, ","); got != "begin:accounts.feishu.cn,poll:accounts.feishu.cn" {
|
||||
t.Fatalf("registration hosts = %q, want Feishu begin and poll", got)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !cfg.Bot.Enabled || !cfg.Bot.Feishu.Enabled || cfg.Bot.Feishu.Domain != "feishu" || cfg.Bot.Feishu.AppID != "cli-feishu" {
|
||||
t.Fatalf("saved feishu config = %+v, want enabled Feishu websocket config", cfg.Bot.Feishu)
|
||||
}
|
||||
if len(cfg.Bot.Allowlist.FeishuUsers) != 1 || cfg.Bot.Allowlist.FeishuUsers[0] != "ou-feishu-installer" {
|
||||
t.Fatalf("feishu allowlist = %+v, want installer open_id", cfg.Bot.Allowlist.FeishuUsers)
|
||||
}
|
||||
if err := os.Unsetenv("FEISHU_BOT_APP_SECRET"); err != nil {
|
||||
t.Fatalf("unset feishu secret env: %v", err)
|
||||
}
|
||||
reloaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if got := os.Getenv("FEISHU_BOT_APP_SECRET"); got != "secret-feishu" {
|
||||
t.Fatalf("reloaded FEISHU_BOT_APP_SECRET = %q, want persisted secret", got)
|
||||
}
|
||||
if len(reloaded.Bot.Connections) != 1 || !botConnectionView(reloaded.Bot.Connections[0]).Credential.SecretSet {
|
||||
t.Fatalf("reloaded connections = %+v, want secret to survive restart", reloaded.Bot.Connections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeixinInstallStoresSavedAccountAndConnection(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
withRewrittenHTTP(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/ilink/bot/get_bot_qrcode":
|
||||
if r.URL.Query().Get("bot_type") != "3" {
|
||||
http.Error(w, "missing bot type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"qrcode": "qr-weixin",
|
||||
"qrcode_img_content": "data:image/png;base64,abc",
|
||||
})
|
||||
case "/ilink/bot/get_qrcode_status":
|
||||
if r.URL.Query().Get("qrcode") != "qr-weixin" {
|
||||
http.Error(w, "wrong qr", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeJSON(t, w, map[string]any{
|
||||
"status": "confirmed",
|
||||
"ilink_bot_id": "weixin-account",
|
||||
"bot_token": "token-1",
|
||||
"ilink_user_id": "user-1",
|
||||
"baseurl": "https://ilinkai.weixin.qq.com",
|
||||
})
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
|
||||
app := NewApp()
|
||||
start, err := app.StartBotConnectionInstall("weixin", "")
|
||||
if err != nil {
|
||||
t.Fatalf("StartBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !start.OK || start.Provider != "weixin" || start.Domain != "weixin" || start.URL != "data:image/png;base64,abc" || start.DeviceCode != "qr-weixin" {
|
||||
t.Fatalf("start result = %+v, want weixin QR result", start)
|
||||
}
|
||||
|
||||
poll, err := app.PollBotConnectionInstall(start.InstallID)
|
||||
if err != nil {
|
||||
t.Fatalf("PollBotConnectionInstall: %v", err)
|
||||
}
|
||||
if !poll.Done {
|
||||
t.Fatalf("poll result = %+v, want done", poll)
|
||||
}
|
||||
if poll.Connection.Provider != "weixin" || poll.Connection.Domain != "weixin" || poll.Connection.Credential.AccountID != "weixin-account" {
|
||||
t.Fatalf("connection = %+v, want weixin account connection", poll.Connection)
|
||||
}
|
||||
if poll.Connection.WorkspaceRoot != "" {
|
||||
t.Fatalf("connection workspaceRoot = %q, want empty global default", poll.Connection.WorkspaceRoot)
|
||||
}
|
||||
if poll.Connection.Credential.TokenEnv != "WEIXIN_BOT_TOKEN" || !poll.Connection.Credential.SecretSet {
|
||||
t.Fatalf("credential = %+v, want saved account to count as configured token", poll.Connection.Credential)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if !cfg.Bot.Enabled || !cfg.Bot.Weixin.Enabled || cfg.Bot.Weixin.AccountID != "weixin-account" || cfg.Bot.Weixin.TokenEnv != "WEIXIN_BOT_TOKEN" {
|
||||
t.Fatalf("saved weixin config = %+v, want enabled saved account", cfg.Bot.Weixin)
|
||||
}
|
||||
if len(cfg.Bot.Allowlist.WeixinUsers) != 1 || cfg.Bot.Allowlist.WeixinUsers[0] != "user-1" {
|
||||
t.Fatalf("weixin allowlist = %+v, want installer user id", cfg.Bot.Allowlist.WeixinUsers)
|
||||
}
|
||||
reloaded, err := config.Load()
|
||||
if err != nil {
|
||||
t.Fatalf("reload config: %v", err)
|
||||
}
|
||||
if len(reloaded.Bot.Connections) != 1 {
|
||||
t.Fatalf("reloaded connections = %+v, want saved weixin connection", reloaded.Bot.Connections)
|
||||
}
|
||||
reloadedConnection := botConnectionView(reloaded.Bot.Connections[0])
|
||||
if reloadedConnection.Credential.AccountID != "weixin-account" || !reloadedConnection.Credential.SecretSet {
|
||||
t.Fatalf("reloaded credential = %+v, want saved weixin account to survive restart", reloadedConnection.Credential)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFeishuRegistrationQRCodeURLAddsSDKMetadata(t *testing.T) {
|
||||
qrURL, err := feishuRegistrationQRCodeURL("https://open.larksuite.com/page/launcher?user_code=ABCD-1234&source=old")
|
||||
if err != nil {
|
||||
t.Fatalf("feishuRegistrationQRCodeURL: %v", err)
|
||||
}
|
||||
parsed, err := url.Parse(qrURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse QR URL: %v", err)
|
||||
}
|
||||
query := parsed.Query()
|
||||
if query.Get("user_code") != "ABCD-1234" {
|
||||
t.Fatalf("user_code = %q, want preserved code", query.Get("user_code"))
|
||||
}
|
||||
if query.Get("from") != "sdk" || query.Get("tp") != "sdk" || query.Get("source") != "go-sdk" {
|
||||
t.Fatalf("query = %v, want SDK registration metadata", query)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseBotConnectionBuildsReportDetailForMissingSecret(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Setenv("FEISHU_BOT_APP_SECRET_PRIVATE", "")
|
||||
app := NewApp()
|
||||
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Label: "Lark",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
WorkspaceRoot: "/Users/alice/work/reasonix",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: "cli-private",
|
||||
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
|
||||
},
|
||||
SessionMappings: []config.BotConnectionSessionMapping{{
|
||||
RemoteID: "ou-private",
|
||||
SessionID: "session-private",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: "/Users/alice/work/reasonix",
|
||||
}},
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("upsert connection: %v", err)
|
||||
}
|
||||
|
||||
diag, err := app.DiagnoseBotConnection("feishu-lark")
|
||||
if err != nil {
|
||||
t.Fatalf("DiagnoseBotConnection: %v", err)
|
||||
}
|
||||
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
|
||||
t.Fatalf("diagnostic = %+v, want warning credential report", diag)
|
||||
}
|
||||
for _, leaked := range []string{"FEISHU_BOT_APP_SECRET_PRIVATE", "/Users/alice", "ou-private", "session-private"} {
|
||||
if strings.Contains(diag.ReportDetail, leaked) {
|
||||
t.Fatalf("diagnostic report leaked %q in %s", leaked, diag.ReportDetail)
|
||||
}
|
||||
}
|
||||
var payload frontendCrashPayload
|
||||
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
|
||||
t.Fatalf("report detail is not structured JSON: %v", err)
|
||||
}
|
||||
if payload.Kind != "bot" || payload.Source != "bot.runtime" || payload.Label != "bot.feishu.lark.credential" {
|
||||
t.Fatalf("payload = %+v, want bot runtime credential label", payload)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"app_secret_env_configured: true",
|
||||
"secret_available: false",
|
||||
"workspace_scope: project",
|
||||
"session_mappings: 1",
|
||||
"summary: required bot credential is not available",
|
||||
} {
|
||||
if !strings.Contains(payload.Message, want) {
|
||||
t.Fatalf("payload message = %q, want it to contain %q", payload.Message, want)
|
||||
}
|
||||
}
|
||||
report, err := crashReportFromDetail(diag.ReportKind, diag.ReportDetail)
|
||||
if err != nil {
|
||||
t.Fatalf("crashReportFromDetail: %v", err)
|
||||
}
|
||||
if report.Kind != "bot" || report.Source != "bot.runtime" || report.ErrorType != "BotConnectionDiagnostic" {
|
||||
t.Fatalf("report = %+v, want accepted bot report", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotConnectionSendFailureReportRedactsEnvNames(t *testing.T) {
|
||||
conn := config.BotConnectionConfig{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Label: "Lark",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppSecretEnv: "FEISHU_BOT_APP_SECRET_PRIVATE",
|
||||
},
|
||||
}
|
||||
diag := botConnectionDiagnostic(&conn, conn.ID, "error", "send", "test_send_failed", "feishu app_id or FEISHU_BOT_APP_SECRET_PRIVATE is not configured", true)
|
||||
if diag.ReportKind != "bot" || diag.ReportDetail == "" {
|
||||
t.Fatalf("diagnostic = %+v, want reportable bot diagnostic", diag)
|
||||
}
|
||||
if strings.Contains(diag.ReportDetail, "FEISHU_BOT_APP_SECRET_PRIVATE") {
|
||||
t.Fatalf("diagnostic report leaked env name in %s", diag.ReportDetail)
|
||||
}
|
||||
var payload frontendCrashPayload
|
||||
if err := json.Unmarshal([]byte(diag.ReportDetail), &payload); err != nil {
|
||||
t.Fatalf("report detail is not structured JSON: %v", err)
|
||||
}
|
||||
if !strings.Contains(payload.ErrorMessage, "[redacted-env]") {
|
||||
t.Fatalf("payload errorMessage = %q, want redacted env marker", payload.ErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiagnoseWeixinConnectionDetectsMissingSavedAccountWithoutTokenEnv(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: "weixin-weixin",
|
||||
Provider: "weixin",
|
||||
Domain: "weixin",
|
||||
Label: "微信",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AccountID: "missing-account",
|
||||
},
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("upsert connection: %v", err)
|
||||
}
|
||||
|
||||
diag, err := app.DiagnoseBotConnection("weixin-weixin")
|
||||
if err != nil {
|
||||
t.Fatalf("DiagnoseBotConnection: %v", err)
|
||||
}
|
||||
if diag.Status != "warning" || diag.Phase != "credential" || diag.Code != "secret_missing" || diag.ReportKind != "bot" || diag.ReportDetail == "" {
|
||||
t.Fatalf("diagnostic = %+v, want missing local credential warning", diag)
|
||||
}
|
||||
if strings.Contains(diag.ReportDetail, "missing-account") {
|
||||
t.Fatalf("diagnostic report leaked account id in %s", diag.ReportDetail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberBotConnectionRemoteStoresStableScope(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Label: "kun",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("upsert global connection: %v", err)
|
||||
}
|
||||
if err := app.rememberBotConnectionRemote("feishu-lark", "ou_global"); err != nil {
|
||||
t.Fatalf("remember global remote: %v", err)
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
if got := cfg.Bot.Connections[0].SessionMappings[0]; got.Scope != "global" || got.WorkspaceRoot != "" || got.RemoteID != "ou_global" {
|
||||
t.Fatalf("global mapping = %+v, want scope=global without workspace", got)
|
||||
}
|
||||
|
||||
if _, err := app.upsertBotConnection(config.BotConnectionConfig{
|
||||
ID: "weixin-project",
|
||||
Provider: "weixin",
|
||||
Domain: "weixin",
|
||||
Label: "project",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
WorkspaceRoot: "/tmp/reasonix-project",
|
||||
}, nil); err != nil {
|
||||
t.Fatalf("upsert project connection: %v", err)
|
||||
}
|
||||
if err := app.rememberBotConnectionRemote("weixin-project", "wxid_project"); err != nil {
|
||||
t.Fatalf("remember project remote: %v", err)
|
||||
}
|
||||
cfg = config.LoadForEdit(config.UserConfigPath())
|
||||
var projectMapping config.BotConnectionSessionMapping
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if conn.ID == "weixin-project" && len(conn.SessionMappings) == 1 {
|
||||
projectMapping = conn.SessionMappings[0]
|
||||
}
|
||||
}
|
||||
if projectMapping.Scope != "project" || projectMapping.WorkspaceRoot != "/tmp/reasonix-project" || projectMapping.RemoteID != "wxid_project" {
|
||||
t.Fatalf("project mapping = %+v, want project scope and workspace", projectMapping)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(value); err != nil {
|
||||
t.Fatalf("write json: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func withRewrittenHTTP(t *testing.T, handler http.Handler) {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
target, err := url.Parse(server.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := http.DefaultTransport
|
||||
http.DefaultTransport = rewriteHTTPTransport{target: target, next: previous}
|
||||
t.Cleanup(func() {
|
||||
http.DefaultTransport = previous
|
||||
server.Close()
|
||||
})
|
||||
}
|
||||
|
||||
type rewriteHTTPTransport struct {
|
||||
target *url.URL
|
||||
next http.RoundTripper
|
||||
}
|
||||
|
||||
func (r rewriteHTTPTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
clone := req.Clone(req.Context())
|
||||
clone.Header.Set("X-Test-Original-Host", req.URL.Host)
|
||||
clone.URL.Scheme = r.target.Scheme
|
||||
clone.URL.Host = r.target.Host
|
||||
clone.Host = r.target.Host
|
||||
if r.next == nil {
|
||||
r.next = http.DefaultTransport
|
||||
}
|
||||
clone.URL.Path = "/" + strings.TrimLeft(clone.URL.Path, "/")
|
||||
return r.next.RoundTrip(clone)
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const botForwardSendTimeout = 30 * time.Second
|
||||
const botForwardQueueSize = 64
|
||||
|
||||
// ── Forward target ──────────────────────────────────────────────────────────
|
||||
|
||||
// botForwardTarget identifies one remote chat to send forwarded events to.
|
||||
type botForwardTarget struct {
|
||||
ConnID string
|
||||
Domain string
|
||||
ChatID string
|
||||
ChatType bot.ChatType
|
||||
}
|
||||
|
||||
// ── Event forwarder ─────────────────────────────────────────────────────────
|
||||
|
||||
// botEventForwarder implements event.Sink and forwards relevant events to
|
||||
// connected bot channels through the desktopBotRuntime. It is attached to a
|
||||
// tabEventSink when a heartbeat task should push AI output to IM channels.
|
||||
//
|
||||
// It accumulates Text events and sends them as complete messages on TurnDone
|
||||
// (and occasionally during generation when the buffer grows large enough), so
|
||||
// the remote side sees progressive streaming output rather than one big blob.
|
||||
type botEventForwarder struct {
|
||||
runtime *desktopBotRuntime
|
||||
targets []botForwardTarget
|
||||
|
||||
mu sync.Mutex
|
||||
buf strings.Builder
|
||||
queueMu sync.Mutex
|
||||
queue chan string
|
||||
closed bool
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// newBotEventForwarder creates a forwarder that sends to all given targets.
|
||||
// runtime may be nil — Emit calls are then no-ops.
|
||||
func newBotEventForwarder(runtime *desktopBotRuntime, targets []botForwardTarget) *botEventForwarder {
|
||||
f := &botEventForwarder{
|
||||
runtime: runtime,
|
||||
targets: targets,
|
||||
queue: make(chan string, botForwardQueueSize),
|
||||
}
|
||||
go f.run()
|
||||
return f
|
||||
}
|
||||
|
||||
// Emit implements event.Sink. It forwards text and lifecycle events to the
|
||||
// connected bot channels; reasoning, tool dispatch, and other internal events
|
||||
// are dropped to avoid noisy IM output.
|
||||
func (f *botEventForwarder) Emit(e event.Event) {
|
||||
if f.runtime == nil || len(f.targets) == 0 {
|
||||
return
|
||||
}
|
||||
switch e.Kind {
|
||||
case event.TurnStarted:
|
||||
f.mu.Lock()
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
case event.Text:
|
||||
f.mu.Lock()
|
||||
f.buf.WriteString(e.Text)
|
||||
size := f.buf.Len()
|
||||
f.mu.Unlock()
|
||||
// Flush opportunistically when the buffer crosses a threshold, so long
|
||||
// streams (e.g. "tell me three jokes") produce multiple messages.
|
||||
if size >= 400 {
|
||||
f.flush()
|
||||
}
|
||||
|
||||
case event.TurnDone:
|
||||
f.flush()
|
||||
f.Close()
|
||||
|
||||
case event.ApprovalRequest:
|
||||
// The heartbeat turn belongs to the desktop tab controller, not the bot
|
||||
// gateway session, so remote /approve replies cannot satisfy this ID.
|
||||
text := "⚠️ 需要在 Reasonix 桌面端批准操作: " + e.Approval.Tool + " — " + e.Approval.Subject
|
||||
text += "\n请回到桌面窗口处理。"
|
||||
f.sendToAll(text)
|
||||
|
||||
case event.AskRequest:
|
||||
var qb strings.Builder
|
||||
qb.WriteString("❓ 需要在 Reasonix 桌面端回答问题:\n")
|
||||
for i, q := range e.Ask.Questions {
|
||||
if i > 0 {
|
||||
qb.WriteString("\n")
|
||||
}
|
||||
qb.WriteString(q.Prompt)
|
||||
}
|
||||
qb.WriteString("\n请回到桌面窗口处理。")
|
||||
f.sendToAll(qb.String())
|
||||
|
||||
case event.Notice:
|
||||
if e.Audience == event.NoticeAudienceOperator {
|
||||
// Local runtime maintenance is not actionable in the remote IM chat.
|
||||
break
|
||||
}
|
||||
if e.Level == event.LevelWarn {
|
||||
f.sendToAll("⚠️ " + e.Text)
|
||||
}
|
||||
|
||||
case event.CompactionStarted:
|
||||
f.sendToAll("🔄 正在压缩上下文...")
|
||||
}
|
||||
}
|
||||
|
||||
// flush sends the accumulated buffer as one message per target channel.
|
||||
func (f *botEventForwarder) flush() {
|
||||
f.mu.Lock()
|
||||
text := strings.TrimSpace(f.buf.String())
|
||||
if text == "" {
|
||||
f.mu.Unlock()
|
||||
return
|
||||
}
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
|
||||
f.sendToAll(text)
|
||||
}
|
||||
|
||||
// sendToAll dispatches text to every target channel. Errors are logged and
|
||||
// non-fatal; a failed target does not block other targets.
|
||||
func (f *botEventForwarder) sendToAll(text string) {
|
||||
text = strings.TrimSpace(text)
|
||||
if f.runtime == nil || len(f.targets) == 0 || text == "" {
|
||||
return
|
||||
}
|
||||
f.queueMu.Lock()
|
||||
defer f.queueMu.Unlock()
|
||||
if f.closed {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case f.queue <- text:
|
||||
default:
|
||||
log.Printf("[bot-forward] send queue full; dropping message for %d target(s)", len(f.targets))
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) run() {
|
||||
for text := range f.queue {
|
||||
f.sendToAllNow(text)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) sendToAllNow(text string) {
|
||||
for _, tgt := range f.targets {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), botForwardSendTimeout)
|
||||
_, err := f.runtime.SendToAdapter(ctx, tgt.ConnID, tgt.Domain, bot.OutboundMessage{
|
||||
ChatID: tgt.ChatID,
|
||||
ChatType: tgt.ChatType,
|
||||
Text: text,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("[bot-forward] send to %s/%s failed: %v", tgt.ConnID, tgt.ChatType, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *botEventForwarder) Close() {
|
||||
f.closeOnce.Do(func() {
|
||||
f.flush()
|
||||
f.queueMu.Lock()
|
||||
f.closed = true
|
||||
close(f.queue)
|
||||
f.queueMu.Unlock()
|
||||
})
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type desktopForwardTestAdapter struct {
|
||||
platform bot.Platform
|
||||
name string
|
||||
messages chan bot.InboundMessage
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
sent chan bot.OutboundMessage
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func newDesktopForwardTestAdapter() *desktopForwardTestAdapter {
|
||||
return &desktopForwardTestAdapter{
|
||||
platform: bot.PlatformFeishu,
|
||||
name: "forward-test",
|
||||
messages: make(chan bot.InboundMessage),
|
||||
entered: make(chan struct{}),
|
||||
release: make(chan struct{}),
|
||||
sent: make(chan bot.OutboundMessage, 8),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *desktopForwardTestAdapter) Platform() bot.Platform { return a.platform }
|
||||
func (a *desktopForwardTestAdapter) Name() string { return a.name }
|
||||
func (a *desktopForwardTestAdapter) Start(context.Context) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Stop() error { return nil }
|
||||
func (a *desktopForwardTestAdapter) SendTyping(context.Context, string) error {
|
||||
return nil
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Messages() <-chan bot.InboundMessage {
|
||||
return a.messages
|
||||
}
|
||||
func (a *desktopForwardTestAdapter) Send(ctx context.Context, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
a.once.Do(func() { close(a.entered) })
|
||||
select {
|
||||
case <-a.release:
|
||||
case <-ctx.Done():
|
||||
return bot.SendResult{}, ctx.Err()
|
||||
}
|
||||
a.sent <- msg
|
||||
return bot.SendResult{MessageID: "sent"}, nil
|
||||
}
|
||||
|
||||
func newDesktopForwardTestRuntime(adapter bot.Adapter) *desktopBotRuntime {
|
||||
gw := bot.NewGatewayWithAdapterBindings(bot.GatewayConfig{}, []bot.AdapterBinding{{
|
||||
ID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
Platform: bot.PlatformFeishu,
|
||||
Adapter: adapter,
|
||||
}}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
return &desktopBotRuntime{gw: gw}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderDoesNotBlockEventEmissionOnSlowSend(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
forwarder.Emit(event.Event{Kind: event.Text, Text: strings.Repeat("x", 400)})
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("Emit blocked behind slow bot send")
|
||||
}
|
||||
select {
|
||||
case <-adapter.entered:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("adapter send did not start")
|
||||
}
|
||||
|
||||
forwarder.Close()
|
||||
close(adapter.release)
|
||||
select {
|
||||
case <-adapter.sent:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("queued bot message was not sent after adapter release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderApprovalNoticeDoesNotExposeReplyID(t *testing.T) {
|
||||
adapter := newDesktopForwardTestAdapter()
|
||||
close(adapter.release)
|
||||
forwarder := newBotEventForwarder(newDesktopForwardTestRuntime(adapter), []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}})
|
||||
|
||||
forwarder.Emit(event.Event{Kind: event.ApprovalRequest, Approval: event.Approval{
|
||||
ID: "approval-1",
|
||||
Tool: "shell",
|
||||
Subject: "run command",
|
||||
}})
|
||||
forwarder.Close()
|
||||
|
||||
select {
|
||||
case msg := <-adapter.sent:
|
||||
if strings.Contains(msg.Text, "approval-1") || strings.Contains(msg.Text, "/approve") {
|
||||
t.Fatalf("approval notice exposed unusable reply routing: %q", msg.Text)
|
||||
}
|
||||
if !strings.Contains(msg.Text, "桌面") {
|
||||
t.Fatalf("approval notice = %q, want desktop guidance", msg.Text)
|
||||
}
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
t.Fatal("approval notice was not sent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBotEventForwarderSuppressesOperatorNoticesWithoutHidingUserWarnings(t *testing.T) {
|
||||
forwarder := &botEventForwarder{
|
||||
runtime: &desktopBotRuntime{},
|
||||
targets: []botForwardTarget{{
|
||||
ConnID: "feishu-lark",
|
||||
Domain: "lark",
|
||||
ChatID: "oc-group-1",
|
||||
ChatType: bot.ChatGroup,
|
||||
}},
|
||||
queue: make(chan string, 2),
|
||||
}
|
||||
|
||||
forwarder.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Audience: event.NoticeAudienceOperator,
|
||||
Code: event.NoticeCodeSessionRecoveryForked,
|
||||
Text: "local session maintenance",
|
||||
})
|
||||
forwarder.Emit(event.Event{
|
||||
Kind: event.Notice,
|
||||
Level: event.LevelWarn,
|
||||
Text: "please retry the user action",
|
||||
})
|
||||
|
||||
if got := len(forwarder.queue); got != 1 {
|
||||
t.Fatalf("queued messages = %d, want only the actionable user warning", got)
|
||||
}
|
||||
if got := <-forwarder.queue; got != "⚠️ please retry the user action" {
|
||||
t.Fatalf("queued message = %q, want the ordinary user warning", got)
|
||||
}
|
||||
}
|
||||
@@ -1,415 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
type BotRuntimeStatusView struct {
|
||||
Running bool `json:"running"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Connections int `json:"connections"`
|
||||
StartedAt string `json:"startedAt"`
|
||||
}
|
||||
|
||||
type desktopBotRuntime struct {
|
||||
// lifecycleMu serializes start/stop transitions so two apply/stop calls
|
||||
// can't race a gateway into existence. The slow work (gw.Stop teardown,
|
||||
// gw.Start dials) runs while holding it but NOT r.mu, so status/send reads
|
||||
// never block on a restart.
|
||||
lifecycleMu sync.Mutex
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc
|
||||
gw *bot.BotGateway
|
||||
status BotRuntimeStatusView
|
||||
}
|
||||
|
||||
func newDesktopBotRuntime() *desktopBotRuntime {
|
||||
return &desktopBotRuntime{status: BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}}
|
||||
}
|
||||
|
||||
func desktopBotChannelsWithLegacyQQ(qq config.QQBotConfig, channels map[bot.Platform]bot.ChannelConfig, connectionChannels map[string]bot.ChannelConfig) (map[bot.Platform]bot.ChannelConfig, map[string]bot.ChannelConfig) {
|
||||
channel := bot.ChannelConfig{
|
||||
Model: strings.TrimSpace(qq.Model),
|
||||
ToolApprovalMode: normalizeBotConnectionToolApprovalMode(qq.ToolApprovalMode),
|
||||
WorkspaceRoot: strings.TrimSpace(qq.WorkspaceRoot),
|
||||
}
|
||||
if channel.Model == "" && channel.ToolApprovalMode == "" && channel.WorkspaceRoot == "" {
|
||||
return channels, connectionChannels
|
||||
}
|
||||
if channels == nil {
|
||||
channels = make(map[bot.Platform]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := channels[bot.PlatformQQ]; !ok {
|
||||
channels[bot.PlatformQQ] = channel
|
||||
}
|
||||
if connectionChannels == nil {
|
||||
connectionChannels = make(map[string]bot.ChannelConfig)
|
||||
}
|
||||
if _, ok := connectionChannels[string(bot.PlatformQQ)]; !ok {
|
||||
connectionChannels[string(bot.PlatformQQ)] = channel
|
||||
}
|
||||
return channels, connectionChannels
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntimeAsync() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
a.goSafe("refreshBotRuntime", a.refreshBotRuntime)
|
||||
}
|
||||
|
||||
func (a *App) refreshBotRuntime() {
|
||||
// NewApp always pre-fills botRuntime; a nil here means a test-constructed
|
||||
// App with no bot runtime, which must not lazily create one from a
|
||||
// background goroutine (that would race a concurrent refresh).
|
||||
if a.botRuntime == nil {
|
||||
return
|
||||
}
|
||||
var watcherVersion uint64
|
||||
if a.botBridge != nil {
|
||||
watcherVersion = a.botBridge.watcherVersion()
|
||||
}
|
||||
cfg, err := a.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
a.botRuntime.stop("error", err.Error())
|
||||
return
|
||||
}
|
||||
// Assign through a typed local so a nil *botBridgeHub never becomes a
|
||||
// non-nil bot.DesktopBridge interface inside the gateway config.
|
||||
var bridge bot.DesktopBridge
|
||||
if a.botBridge != nil {
|
||||
// 配置是订阅的持久化事实源:每次运行时重算前重新种子,桌面重启后
|
||||
// /desktop watch 的订阅继续生效。
|
||||
a.botBridge.seedWatchers(bridgeRoutesFromConfig(cfg.Bot.DesktopWatchers), watcherVersion)
|
||||
bridge = a.botBridge
|
||||
}
|
||||
_ = a.botRuntime.apply(a.bootContext(), cfg, globalTabWorkspaceRoot(), a.persistRemoteBotToolApprovalMode, bridge)
|
||||
}
|
||||
|
||||
func (a *App) loadDesktopBotConfig() (*config.Config, error) {
|
||||
// Read-only load feeding the bot runtime and connection diagnostics. It
|
||||
// must load credentials: the runtime resolves app secrets and control
|
||||
// tokens from the process env (AppSecretEnv, Control.TokenEnv), which the
|
||||
// credential-free view load would leave unset on a fresh process.
|
||||
cfg, _, err := a.loadDesktopUserConfigForViewWithCredentials()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (a *App) stopBotRuntime() {
|
||||
if a.botRuntime != nil {
|
||||
a.botRuntime.stop("stopped", "bot runtime stopped")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) BotRuntimeStatus() BotRuntimeStatusView {
|
||||
if a.botRuntime == nil {
|
||||
return BotRuntimeStatusView{Status: "stopped", Message: "bot runtime is not started"}
|
||||
}
|
||||
return a.botRuntime.snapshot()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) apply(parent context.Context, cfg *config.Config, workspaceRoot string, onToolApprovalModeChange func(bot.InboundMessage, string) error, bridge bot.DesktopBridge) error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if parent == nil {
|
||||
parent = context.Background()
|
||||
}
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
if !plan.Start {
|
||||
r.setStatus(BotRuntimeStatusView{Status: plan.Status, Message: plan.Message})
|
||||
return nil
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
modelName := botruntime.ModelName(cfg, "")
|
||||
channels := botruntime.ChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
connectionChannels := botruntime.ConnectionChannelConfigs(cfg.Bot.Connections, true, true)
|
||||
channels, connectionChannels = desktopBotChannelsWithLegacyQQ(cfg.Bot.QQ, channels, connectionChannels)
|
||||
gwCfg := bot.GatewayConfig{
|
||||
Model: modelName,
|
||||
ToolApprovalMode: cfg.Bot.ToolApprovalMode,
|
||||
MaxSteps: cfg.Bot.MaxSteps,
|
||||
QueueMode: cfg.Bot.QueueMode,
|
||||
QueueCap: cfg.Bot.QueueCap,
|
||||
QueueDrop: cfg.Bot.QueueDrop,
|
||||
PairingEnabled: cfg.Bot.Pairing.Enabled,
|
||||
PairingTTL: time.Duration(cfg.Bot.Pairing.RequestTTLMinutes) * time.Minute,
|
||||
PairingMaxPending: cfg.Bot.Pairing.MaxPendingPerPlatform,
|
||||
IgnoreSelfMessages: cfg.Bot.IgnoreSelfMessages,
|
||||
SelfUserIDs: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.SelfUserIDs.QQ,
|
||||
bot.PlatformFeishu: cfg.Bot.SelfUserIDs.Feishu,
|
||||
bot.PlatformWeixin: cfg.Bot.SelfUserIDs.Weixin,
|
||||
},
|
||||
ControlEnabled: cfg.Bot.Control.Enabled,
|
||||
ControlAddr: cfg.Bot.Control.Addr,
|
||||
ControlToken: os.Getenv(strings.TrimSpace(cfg.Bot.Control.TokenEnv)),
|
||||
WorkspaceRoot: workspaceRoot,
|
||||
Channels: channels,
|
||||
ConnectionChannels: connectionChannels,
|
||||
Routes: botruntime.RouteConfigs(cfg.Bot.Routes, true, true),
|
||||
ConnectionAccess: botruntime.ConnectionAccessConfigs(cfg),
|
||||
Enabled: plan.Enabled,
|
||||
Allowlist: bot.AllowlistConfig{
|
||||
Enabled: cfg.Bot.Allowlist.Enabled,
|
||||
AllowAll: cfg.Bot.Allowlist.AllowAll,
|
||||
Users: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQUsers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuUsers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinUsers,
|
||||
},
|
||||
Approvers: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQApprovers,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuApprovers,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinApprovers,
|
||||
},
|
||||
Admins: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQAdmins,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuAdmins,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinAdmins,
|
||||
},
|
||||
Groups: map[bot.Platform][]string{
|
||||
bot.PlatformQQ: cfg.Bot.Allowlist.QQGroups,
|
||||
bot.PlatformFeishu: cfg.Bot.Allowlist.FeishuGroups,
|
||||
bot.PlatformWeixin: cfg.Bot.Allowlist.WeixinGroups,
|
||||
},
|
||||
},
|
||||
Debounce: time.Duration(cfg.Bot.DebounceMs) * time.Millisecond,
|
||||
OnInbound: botruntime.NewRemoteRememberer(logger),
|
||||
OnSessionReady: botruntime.NewSessionRemembererWithWorkspace(logger, workspaceRoot),
|
||||
OnToolApprovalModeChange: onToolApprovalModeChange,
|
||||
Desktop: bridge,
|
||||
}
|
||||
bindings := botruntime.AdapterBindings(cfg, plan.Enabled, nil, logger)
|
||||
if len(bindings) == 0 {
|
||||
cancel()
|
||||
r.setStatus(BotRuntimeStatusView{Status: "stopped", Message: "no bot adapters configured"})
|
||||
return nil
|
||||
}
|
||||
gw := bot.NewGatewayWithAdapterBindings(gwCfg, bindings, logger)
|
||||
if err := gw.Start(ctx); err != nil {
|
||||
cancel()
|
||||
gw.Stop()
|
||||
r.setStatus(BotRuntimeStatusView{Status: "error", Message: err.Error(), Connections: gw.AdapterCount()})
|
||||
return err
|
||||
}
|
||||
runningConnections := gw.AdapterCount()
|
||||
startErrors := gw.StartErrors()
|
||||
status := "running"
|
||||
message := fmt.Sprintf("%d bot connection(s) running", runningConnections)
|
||||
if len(startErrors) > 0 {
|
||||
status = "degraded"
|
||||
message = fmt.Sprintf("%d bot connection(s) running; %d failed to start: %s", runningConnections, len(startErrors), summarizeBotRuntimeErrors(startErrors))
|
||||
}
|
||||
r.mu.Lock()
|
||||
r.cancel = cancel
|
||||
r.gw = gw
|
||||
r.status = BotRuntimeStatusView{
|
||||
Running: true,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Connections: runningConnections,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
r.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) persistRemoteBotToolApprovalMode(msg bot.InboundMessage, mode string) error {
|
||||
mode = normalizeBotConnectionToolApprovalMode(mode)
|
||||
if mode == "" {
|
||||
return nil
|
||||
}
|
||||
return a.applyConfigOnly(func(c *config.Config) error {
|
||||
id := strings.TrimSpace(msg.ConnectionID)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if id != "" {
|
||||
for i := range c.Bot.Connections {
|
||||
if c.Bot.Connections[i].ID == id || botruntime.ConnectionRuntimeID(c.Bot.Connections[i]) == id {
|
||||
c.Bot.Connections[i].ToolApprovalMode = mode
|
||||
c.Bot.Connections[i].UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
c.Bot.ToolApprovalMode = mode
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func summarizeBotRuntimeErrors(errs []error) string {
|
||||
parts := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, err.Error())
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
hidden := len(parts) - 3
|
||||
parts = append(parts[:3], fmt.Sprintf("%d more", hidden))
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
type botRuntimePlan struct {
|
||||
Start bool
|
||||
Status string
|
||||
Message string
|
||||
Enabled map[bot.Platform]bool
|
||||
}
|
||||
|
||||
func desktopBotRuntimePlan(cfg *config.Config) botRuntimePlan {
|
||||
if cfg == nil {
|
||||
return botRuntimePlan{Status: "error", Message: "config is unavailable"}
|
||||
}
|
||||
if !cfg.Bot.Enabled {
|
||||
return botRuntimePlan{Status: "stopped", Message: "bot is disabled"}
|
||||
}
|
||||
if !botruntime.BotConfigHasAccessControl(cfg.Bot) {
|
||||
return botRuntimePlan{Status: "blocked", Message: "bot requires an allowlist, pairing, per-bot access, or allow_all=true"}
|
||||
}
|
||||
enabled, unknown := botruntime.EnabledPlatforms(cfg, nil)
|
||||
if len(unknown) > 0 {
|
||||
return botRuntimePlan{Status: "error", Message: "unknown bot channel: " + strings.Join(unknown, ", ")}
|
||||
}
|
||||
if !botruntime.HasEnabledPlatform(enabled) {
|
||||
return botRuntimePlan{Status: "stopped", Message: "no bot channels enabled"}
|
||||
}
|
||||
return botRuntimePlan{Start: true, Status: "running", Message: "bot runtime can start", Enabled: enabled}
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) stop(status, message string) {
|
||||
r.lifecycleMu.Lock()
|
||||
defer r.lifecycleMu.Unlock()
|
||||
r.stopCurrent()
|
||||
r.setStatus(BotRuntimeStatusView{Status: status, Message: message})
|
||||
}
|
||||
|
||||
// stopCurrent detaches the running gateway under r.mu, then tears it down
|
||||
// off-lock: gw.Stop() closes every session controller (up to the jobs teardown
|
||||
// grace each) and must not stall status/send readers. Callers hold lifecycleMu.
|
||||
func (r *desktopBotRuntime) stopCurrent() {
|
||||
r.mu.Lock()
|
||||
cancel := r.cancel
|
||||
gw := r.gw
|
||||
r.cancel = nil
|
||||
r.gw = nil
|
||||
r.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
if gw != nil {
|
||||
gw.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) setStatus(status BotRuntimeStatusView) {
|
||||
r.mu.Lock()
|
||||
r.status = status
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
func (r *desktopBotRuntime) snapshot() BotRuntimeStatusView {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.status
|
||||
}
|
||||
|
||||
// updateConnectionToolApprovalMode updates a connection's tool approval mode
|
||||
// on the running gateway without restarting. Returns true if updated, false if
|
||||
// the gateway is not running or the connection is unknown.
|
||||
func (r *desktopBotRuntime) updateConnectionToolApprovalMode(connID, mode string) bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if r.gw == nil {
|
||||
return false
|
||||
}
|
||||
mode = normalizeBotConnectionToolApprovalMode(mode)
|
||||
// Update ConnectionChannels in the internal GatewayConfig so new sessions
|
||||
// pick up the mode. Existing sessions are updated by the gateway directly.
|
||||
r.gw.UpdateConnectionToolApprovalMode(connID, mode)
|
||||
return true
|
||||
}
|
||||
|
||||
// SendToAdapter sends a message through the running gateway's adapter
|
||||
// identified by connID. Returns an error if the gateway is not running
|
||||
// or no matching adapter is found.
|
||||
func (r *desktopBotRuntime) SendToAdapter(ctx context.Context, connID, domain string, msg bot.OutboundMessage) (bot.SendResult, error) {
|
||||
r.mu.Lock()
|
||||
gw := r.gw
|
||||
r.mu.Unlock()
|
||||
if gw == nil {
|
||||
return bot.SendResult{}, nil // gateway not running — silent no-op
|
||||
}
|
||||
return gw.SendToAdapter(ctx, connID, domain, msg)
|
||||
}
|
||||
|
||||
// Running returns true if the bot gateway is currently active.
|
||||
func (r *desktopBotRuntime) Running() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.gw != nil
|
||||
}
|
||||
|
||||
// ForwardTargets returns the list of bot forward targets derived from the
|
||||
// current config's bot connections and their session mappings. Each mapping
|
||||
// produces one target (connID + chatID + chatType) for event forwarding.
|
||||
func (r *desktopBotRuntime) ForwardTargets(cfg *config.Config) []botForwardTarget {
|
||||
if cfg == nil {
|
||||
return nil
|
||||
}
|
||||
var targets []botForwardTarget
|
||||
seen := make(map[botForwardTarget]bool)
|
||||
for _, conn := range cfg.Bot.Connections {
|
||||
if !conn.Enabled {
|
||||
continue
|
||||
}
|
||||
connID := botruntime.ConnectionRuntimeID(conn)
|
||||
domain := strings.TrimSpace(conn.Domain)
|
||||
for _, sm := range conn.SessionMappings {
|
||||
remoteID := strings.TrimSpace(sm.RemoteID)
|
||||
if remoteID == "" {
|
||||
continue
|
||||
}
|
||||
chatType := bot.ChatDM
|
||||
if sm.ChatType != "" {
|
||||
chatType = bot.ChatType(sm.ChatType)
|
||||
}
|
||||
target := botForwardTarget{
|
||||
ConnID: connID,
|
||||
Domain: domain,
|
||||
ChatID: remoteID,
|
||||
ChatType: chatType,
|
||||
}
|
||||
if seen[target] {
|
||||
continue
|
||||
}
|
||||
seen[target] = true
|
||||
targets = append(targets, target)
|
||||
}
|
||||
}
|
||||
return targets
|
||||
}
|
||||
@@ -1,504 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/bot"
|
||||
"reasonix/internal/botruntime"
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsSavedConnections(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
||||
cfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-feishu", Provider: "feishu", Domain: "feishu", Enabled: true},
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start", plan)
|
||||
}
|
||||
if !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
|
||||
t.Fatalf("enabled = %+v, want feishu/lark and weixin platforms", plan.Enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanBlocksWithoutAllowlist(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = false
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if plan.Start || plan.Status != "blocked" {
|
||||
t.Fatalf("plan = %+v, want blocked without allowlist", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStartsWithPairing(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = true
|
||||
cfg.Bot.Allowlist.Enabled = true
|
||||
cfg.Bot.Pairing.Enabled = true
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if !plan.Start {
|
||||
t.Fatalf("plan = %+v, want start with pairing enabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotChannelsWithLegacyQQConfig(t *testing.T) {
|
||||
channels, connectionChannels := desktopBotChannelsWithLegacyQQ(config.QQBotConfig{
|
||||
Model: "qq-model",
|
||||
ToolApprovalMode: "auto",
|
||||
WorkspaceRoot: "/tmp/qq-project",
|
||||
}, nil, nil)
|
||||
|
||||
channel, ok := channels[bot.PlatformQQ]
|
||||
if !ok {
|
||||
t.Fatalf("platform QQ channel missing: %+v", channels)
|
||||
}
|
||||
if channel.Model != "qq-model" || channel.ToolApprovalMode != "auto" || channel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("platform channel = %+v, want QQ-specific runtime fields", channel)
|
||||
}
|
||||
connectionChannel, ok := connectionChannels["qq"]
|
||||
if !ok {
|
||||
t.Fatalf("connection QQ channel missing: %+v", connectionChannels)
|
||||
}
|
||||
if connectionChannel.Model != "qq-model" || connectionChannel.ToolApprovalMode != "auto" || connectionChannel.WorkspaceRoot != "/tmp/qq-project" {
|
||||
t.Fatalf("connection channel = %+v, want QQ-specific runtime fields", connectionChannel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePlanStopsWhenBotDisabled(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Enabled = false
|
||||
cfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true},
|
||||
}
|
||||
|
||||
plan := desktopBotRuntimePlan(cfg)
|
||||
if plan.Start || plan.Status != "stopped" {
|
||||
t.Fatalf("plan = %+v, want stopped when disabled", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeForwardTargetsDeduplicatesMappedChats(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Bot.Connections = []config.BotConnectionConfig{{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Enabled: true,
|
||||
SessionMappings: []config.BotConnectionSessionMapping{
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-1"},
|
||||
{RemoteID: "oc-group-1", ChatType: string(bot.ChatGroup), UserID: "ou-user-2"},
|
||||
{RemoteID: "oc-dm-1", ChatType: string(bot.ChatDM), UserID: "ou-user-1"},
|
||||
},
|
||||
}}
|
||||
|
||||
targets := newDesktopBotRuntime().ForwardTargets(cfg)
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("targets = %+v, want one group target plus one dm target", targets)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, target := range targets {
|
||||
key := target.ConnID + "|" + target.Domain + "|" + target.ChatID + "|" + string(target.ChatType)
|
||||
if seen[key] {
|
||||
t.Fatalf("duplicate target %q in %+v", key, targets)
|
||||
}
|
||||
seen[key] = true
|
||||
}
|
||||
if !seen["feishu-lark|lark|oc-group-1|group"] || !seen["feishu-lark|lark|oc-dm-1|dm"] {
|
||||
t.Fatalf("targets = %+v, want group and dm targets", targets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeConfigUsesUserBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.LoadForEdit(config.UserConfigPath())
|
||||
userCfg.Bot.Enabled = true
|
||||
userCfg.Bot.Allowlist.Enabled = true
|
||||
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-installer"}
|
||||
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "feishu-lark", Provider: "feishu", Domain: "lark", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = false
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
plan := desktopBotRuntimePlan(got)
|
||||
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] {
|
||||
t.Fatalf("desktop runtime plan = %+v, want user-level Lark connection to start", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeConfigLoadsAllSavedCredentialsAfterRestart(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
t.Cleanup(func() {
|
||||
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
|
||||
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
|
||||
})
|
||||
|
||||
userCfg := config.Default()
|
||||
userCfg.Bot.Enabled = true
|
||||
userCfg.Bot.Allowlist.Enabled = true
|
||||
userCfg.Bot.Allowlist.FeishuUsers = []string{"ou-feishu-installer", "ou-lark-installer"}
|
||||
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-installer"}
|
||||
userCfg.Bot.Feishu.Enabled = true
|
||||
userCfg.Bot.Weixin.Enabled = true
|
||||
userCfg.Bot.Weixin.AccountID = "weixin-account"
|
||||
userCfg.Bot.Weixin.TokenEnv = "WEIXIN_BOT_TOKEN"
|
||||
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{
|
||||
ID: "feishu-feishu",
|
||||
Provider: "feishu",
|
||||
Domain: "feishu",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: "cli-feishu",
|
||||
AppSecretEnv: "FEISHU_BOT_APP_SECRET",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "feishu-lark",
|
||||
Provider: "feishu",
|
||||
Domain: "lark",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AppID: "cli-lark",
|
||||
AppSecretEnv: "LARK_BOT_APP_SECRET",
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: "weixin-weixin",
|
||||
Provider: "weixin",
|
||||
Domain: "weixin",
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Credential: config.BotConnectionCredential{
|
||||
AccountID: "weixin-account",
|
||||
TokenEnv: "WEIXIN_BOT_TOKEN",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(config.UserCredentialsPath()), 0o755); err != nil {
|
||||
t.Fatalf("create credentials dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(config.UserCredentialsPath(), []byte("FEISHU_BOT_APP_SECRET=feishu-secret\nLARK_BOT_APP_SECRET=lark-secret\n"), 0o600); err != nil {
|
||||
t.Fatalf("write credentials: %v", err)
|
||||
}
|
||||
weixinAccountPath := filepath.Join(config.MemoryUserDir(), "weixin", "accounts", "weixin-account.json")
|
||||
if err := os.MkdirAll(filepath.Dir(weixinAccountPath), 0o700); err != nil {
|
||||
t.Fatalf("create weixin account dir: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(weixinAccountPath, []byte(`{"token":"weixin-token","base_url":"https://ilinkai.weixin.qq.com","user_id":"wx-installer"}`), 0o600); err != nil {
|
||||
t.Fatalf("write weixin account: %v", err)
|
||||
}
|
||||
_ = os.Unsetenv("FEISHU_BOT_APP_SECRET")
|
||||
_ = os.Unsetenv("LARK_BOT_APP_SECRET")
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
views := botConnectionViews(got.Bot.Connections)
|
||||
if len(views) != 3 {
|
||||
t.Fatalf("connection views = %+v, want Feishu, Lark, and Weixin", views)
|
||||
}
|
||||
for _, view := range views {
|
||||
if !view.Credential.SecretSet {
|
||||
t.Fatalf("connection %s credential = %+v, want saved credential loaded after restart", view.ID, view.Credential)
|
||||
}
|
||||
}
|
||||
plan := desktopBotRuntimePlan(got)
|
||||
if !plan.Start || !plan.Enabled[bot.PlatformFeishu] || !plan.Enabled[bot.PlatformWeixin] {
|
||||
t.Fatalf("desktop runtime plan = %+v, want saved Feishu/Lark/Weixin connections to start", plan)
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
bindings := botruntime.AdapterBindings(got, plan.Enabled, nil, logger)
|
||||
if len(bindings) != 3 {
|
||||
t.Fatalf("adapter bindings = %+v, want one per saved connection", bindings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigratesLegacyProjectBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
if err := userCfg.SetDesktopAppearance("dark", "graphite"); err != nil {
|
||||
t.Fatalf("set desktop appearance: %v", err)
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: the merge above stays in memory and
|
||||
// the user config file is not rewritten.
|
||||
preWrite := config.LoadForEdit(config.UserConfigPath())
|
||||
if preWrite.Bot.Enabled || len(preWrite.Bot.Connections) != 0 {
|
||||
t.Fatalf("read path persisted bot config = %+v, want disk untouched until a locked write", preWrite.Bot)
|
||||
}
|
||||
|
||||
// The first locked write path performs the on-disk migration.
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
if persisted.DesktopTheme() != "dark" {
|
||||
t.Fatalf("desktop theme = %q, want preserved user preference", persisted.DesktopTheme())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimePersistsLegacyProjectBotWhenUserConfigMissing(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
got, err := app.loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if !got.Bot.Enabled || len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("desktop bot config = %+v, want migrated legacy Lark connection", got.Bot)
|
||||
}
|
||||
|
||||
// The bot-runtime load is a pure read: it serves the legacy config from
|
||||
// memory and must not create the user config file.
|
||||
if _, err := os.Stat(config.UserConfigPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("read path must not create the user config, stat err = %v", err)
|
||||
}
|
||||
|
||||
// The first locked write path creates the user config with the migrated
|
||||
// bot settings (adopting the legacy config, ConfigVersion-bumped).
|
||||
if err := app.applyConfigOnly(func(*config.Config) error { return nil }); err != nil {
|
||||
t.Fatalf("applyConfigOnly: %v", err)
|
||||
}
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if !persisted.Bot.Enabled || len(persisted.Bot.Connections) != 1 || persisted.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("persisted bot config = %+v, want migrated legacy Lark connection", persisted.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopSettingsBotMigrationPersistsOnlyBotBeforeFirstEdit(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[desktop]
|
||||
theme = "dark"
|
||||
close_behavior = "quit"
|
||||
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
label = "Lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
settings := NewApp().Settings()
|
||||
if !settings.Bot.Enabled || len(settings.Bot.Connections) != 1 || settings.Bot.Connections[0].ID != "feishu-lark" {
|
||||
t.Fatalf("settings bot = %+v, want migrated legacy Lark connection", settings.Bot)
|
||||
}
|
||||
if settings.DesktopTheme != "dark" || settings.CloseBehavior != "quit" {
|
||||
t.Fatalf("settings desktop prefs = theme:%q close:%q, want legacy seed visible before first edit", settings.DesktopTheme, settings.CloseBehavior)
|
||||
}
|
||||
|
||||
persisted := config.LoadForEdit(config.UserConfigPath())
|
||||
if persisted.DesktopTheme() == "dark" || persisted.DesktopCloseBehavior() == "quit" {
|
||||
t.Fatalf("persisted desktop prefs = theme:%q close:%q, want bot-only migration", persisted.DesktopTheme(), persisted.DesktopCloseBehavior())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopBotRuntimeMigrationDoesNotOverwriteUserBotSettings(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
userCfg := config.Default()
|
||||
userCfg.Bot.Enabled = true
|
||||
userCfg.Bot.Allowlist.Enabled = true
|
||||
userCfg.Bot.Allowlist.WeixinUsers = []string{"wx-user"}
|
||||
userCfg.Bot.Connections = []config.BotConnectionConfig{
|
||||
{ID: "weixin-weixin", Provider: "weixin", Domain: "weixin", Enabled: true, Status: "connected"},
|
||||
}
|
||||
if err := userCfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatalf("save user config: %v", err)
|
||||
}
|
||||
|
||||
project := robustTempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
||||
[bot]
|
||||
enabled = true
|
||||
|
||||
[bot.allowlist]
|
||||
enabled = true
|
||||
feishu_users = ["ou-legacy"]
|
||||
|
||||
[[bot.connections]]
|
||||
id = "feishu-lark"
|
||||
provider = "feishu"
|
||||
domain = "lark"
|
||||
enabled = true
|
||||
status = "connected"
|
||||
`), 0o644); err != nil {
|
||||
t.Fatalf("write project config: %v", err)
|
||||
}
|
||||
|
||||
orig, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(orig) }()
|
||||
if err := os.Chdir(project); err != nil {
|
||||
t.Fatalf("chdir project: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewApp().loadDesktopBotConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("load desktop bot config: %v", err)
|
||||
}
|
||||
if len(got.Bot.Connections) != 1 || got.Bot.Connections[0].ID != "weixin-weixin" {
|
||||
t.Fatalf("desktop bot config = %+v, want existing user WeChat connection", got.Bot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummarizeBotRuntimeErrorsCapsOutput(t *testing.T) {
|
||||
got := summarizeBotRuntimeErrors([]error{
|
||||
errors.New("first"),
|
||||
nil,
|
||||
errors.New("second"),
|
||||
errors.New("third"),
|
||||
errors.New("fourth"),
|
||||
})
|
||||
|
||||
for _, want := range []string{"first", "second", "third", "1 more"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("summary = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "fourth") {
|
||||
t.Fatalf("summary = %q, should cap extra errors", got)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBoundArrayPayloadsAreNonNilBeforeStartup(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
|
||||
app := NewApp()
|
||||
cases := []struct {
|
||||
name string
|
||||
got any
|
||||
}{
|
||||
{"Checkpoints", app.Checkpoints()},
|
||||
{"ListSessions", app.ListSessions()},
|
||||
{"ListTrashedSessions", app.ListTrashedSessions()},
|
||||
{"ListWorkspaces", app.ListWorkspaces()},
|
||||
{"History", app.History()},
|
||||
{"Jobs", app.Jobs()},
|
||||
{"Commands", app.Commands()},
|
||||
{"Models", app.Models()},
|
||||
{"ListDir", app.ListDir("__missing__")},
|
||||
{"ListDirForTab", app.ListDirForTab("missing", "")},
|
||||
{"SearchFileRefsForTab", app.SearchFileRefsForTab("missing", "file")},
|
||||
{"ListTabs", app.ListTabs()},
|
||||
{"ListProjectTree", app.ListProjectTree()},
|
||||
{"AvailableSubagentTools", app.AvailableSubagentTools()},
|
||||
{"MCPServers", app.MCPServers()},
|
||||
{"Plugins", app.Plugins()},
|
||||
{"HeartbeatListTasks", app.HeartbeatListTasks()},
|
||||
{"HeartbeatReloadTasks", app.HeartbeatReloadTasks()},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assertNonNilSliceJSON(t, tc.name, tc.got)
|
||||
}
|
||||
|
||||
if got := app.SlashArgs("/skill "); got.Items == nil {
|
||||
t.Fatal("SlashArgs().Items is nil; frontend expects []")
|
||||
}
|
||||
if got := app.WorkspaceChanges(""); got.Files == nil {
|
||||
t.Fatal(`WorkspaceChanges("").Files is nil; frontend expects []`)
|
||||
}
|
||||
if got := app.ContextPanel("missing"); got.ReadFiles == nil || got.ChangedFiles == nil {
|
||||
t.Fatalf("ContextPanel(missing) arrays = read:%v changed:%v, want non-nil", got.ReadFiles, got.ChangedFiles)
|
||||
}
|
||||
if got := app.HooksSettings("global"); got.Hooks == nil || got.Events == nil {
|
||||
t.Fatalf("HooksSettings(global) arrays = hooks:%v events:%v, want non-nil", got.Hooks, got.Events)
|
||||
}
|
||||
if got := app.Settings(); got.Providers == nil || got.OfficialProviders == nil || got.ProviderPresets == nil || got.ProviderKinds == nil ||
|
||||
got.Permissions.Allow == nil || got.Permissions.Ask == nil || got.Permissions.Deny == nil ||
|
||||
got.Sandbox.AllowWrite == nil || got.Sandbox.EffectiveWriteRoots == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("Settings() contains nil array fields: %+v", got)
|
||||
}
|
||||
if got := app.DesktopStartupSettings(); got.StatusBarItems == nil ||
|
||||
got.Bot.Allowlist.QQUsers == nil || got.Bot.Allowlist.FeishuUsers == nil || got.Bot.Allowlist.WeixinUsers == nil ||
|
||||
got.Bot.Allowlist.QQGroups == nil || got.Bot.Allowlist.FeishuGroups == nil || got.Bot.Allowlist.WeixinGroups == nil {
|
||||
t.Fatalf("DesktopStartupSettings() contains nil array fields: %+v", got)
|
||||
}
|
||||
|
||||
boundPayloads := []struct {
|
||||
name string
|
||||
got any
|
||||
}{
|
||||
{"CapabilityDiagnostics", app.CapabilityDiagnostics(false)},
|
||||
{"Capabilities", app.Capabilities()},
|
||||
{"SkillsSettings", app.SkillsSettings()},
|
||||
{"HistoryPage", app.HistoryPage(0, 20)},
|
||||
{"Effort", app.Effort()},
|
||||
{"Memory", app.Memory()},
|
||||
{"MemorySuggestions", app.MemorySuggestions()},
|
||||
}
|
||||
for _, tc := range boundPayloads {
|
||||
assertRequiredJSONSlicesNonNil(t, tc.name, reflect.ValueOf(tc.got))
|
||||
}
|
||||
}
|
||||
|
||||
func assertNonNilSliceJSON(t *testing.T, name string, got any) {
|
||||
t.Helper()
|
||||
v := reflect.ValueOf(got)
|
||||
if v.Kind() != reflect.Slice {
|
||||
t.Fatalf("%s returned %T, want slice", name, got)
|
||||
}
|
||||
if v.IsNil() {
|
||||
t.Fatalf("%s returned nil slice; frontend expects []", name)
|
||||
}
|
||||
raw, err := json.Marshal(got)
|
||||
if err != nil {
|
||||
t.Fatalf("%s JSON marshal: %v", name, err)
|
||||
}
|
||||
if string(raw) == "null" {
|
||||
t.Fatalf("%s JSON encoded as null; frontend expects []", name)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRequiredJSONSlicesNonNil(t *testing.T, path string, value reflect.Value) {
|
||||
t.Helper()
|
||||
for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer {
|
||||
if value.IsNil() {
|
||||
return
|
||||
}
|
||||
value = value.Elem()
|
||||
}
|
||||
|
||||
switch value.Kind() {
|
||||
case reflect.Slice, reflect.Array:
|
||||
if value.Kind() == reflect.Slice && value.IsNil() {
|
||||
t.Fatalf("%s is a nil slice; JSON contract requires []", path)
|
||||
}
|
||||
for i := range value.Len() {
|
||||
assertRequiredJSONSlicesNonNil(t, path, value.Index(i))
|
||||
}
|
||||
case reflect.Struct:
|
||||
typ := value.Type()
|
||||
for i := range value.NumField() {
|
||||
fieldType := typ.Field(i)
|
||||
if fieldType.PkgPath != "" {
|
||||
continue
|
||||
}
|
||||
jsonTag := fieldType.Tag.Get("json")
|
||||
if jsonTag == "-" || strings.Contains(jsonTag, ",omitempty") {
|
||||
continue
|
||||
}
|
||||
fieldPath := path + "." + fieldType.Name
|
||||
assertRequiredJSONSlicesNonNil(t, fieldPath, value.Field(i))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 61 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
|
||||
<g transform="scale(2.56)">
|
||||
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
|
||||
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
|
||||
<!-- Legacy ICNS renderers do not apply the modern macOS icon mask. Keep the
|
||||
rounded-square artwork in the 824px icon grid used by peer applications. -->
|
||||
<g transform="translate(100 100) scale(2.06)">
|
||||
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
|
||||
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,17 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Hardened-runtime entitlements required to notarize a Wails (Go + WKWebView)
|
||||
app. The web view's JS engine needs JIT and writable/executable memory;
|
||||
the Go binary links system libs that aren't team-signed. Kept minimal — no
|
||||
network/file entitlements, since this is a hardened-runtime carve-out, not
|
||||
an App Sandbox profile. -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Reasonix">
|
||||
<g transform="scale(2.56)">
|
||||
<rect width="400" height="400" rx="126.37" ry="126.37" fill="#0153e5"/>
|
||||
<path fill="#fff" d="M253.29,235.46l.02-.02c14.11-4.16,26.51-11.63,36.45-22.4v-.04c10.82-12.11,17.61-26.76,19.97-42.87,5.39-35.23-12.1-67.41-45.05-81.04-11.59-4.65-23.48-6.64-36.12-6.52l-122.5.04v234.74l60.22-.05v-79.23c6.57,2.11,13.49,3.25,20.59,3.22l19.71,27.21,35.83,48.92,71.52-.1-60.64-81.87ZM250.87,224.64c-8.21,2.71-17.01,2.31-25.39-.14-14.4-10.89-22.2-22.51-34.48-33.59-15.72-14.19-36.52-24.02-57.83-23.06,1.7-4.65,4.14-9.09,7.31-13.16,11-13.74,26-18.1,43.24-15.69,6.36.89,13.16-6.58,26.1-5.14.86.1,1.75.75,1.83,1.27.3,1.77-4.71,2.72-4.71,7.55,0,2.03.91,4.39,2.83,5.73,6.6,4.63,12.32,9.83,18.3,15.21,2.91,2.61,12.76,9.96,15.26,3.98,1.47-3.49,2.43-7.15,3.4-10.86.48-1.84-.54-2.93-2.02-3.8-10.17-6.03-13.99-18.56-10.02-29.67.38-1.07,1.38-1.59,2.22-1.63,3.41-.15,1.59,6.18,10.04,8.78,8.01,2.48,8.25,9.13,12.14,6.47,9.07-6.18,12.34-1.12,20.76-9.05.86-.81,2.36-.87,3.25-.25.56.38,1.04,1.38.99,2.55-.25,6.7-2.86,13.1-7.44,18.01-8.66,9.29-15.93,4.84-16.4,11.36-1.38,19.11-7.63,38.18-21.19,52.06-.44.45-.69,1.08-.62,1.51.07.45.61.89,1.2,1.08l11.42,3.89c1.61.55,2.79,2,2.58,3.54-.18,1.35-1.26,2.54-2.79,3.05Z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB |
@@ -1,29 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policyconfig PUBLIC
|
||||
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
|
||||
"http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
|
||||
<!--
|
||||
Polkit policy for Reasonix desktop in-app .deb upgrades.
|
||||
The action only authorizes the fixed root-owned helper with argv1=install.
|
||||
Active sessions require auth_admin every time; authorization is not retained.
|
||||
-->
|
||||
<policyconfig>
|
||||
<vendor>Reasonix</vendor>
|
||||
<vendor_url>https://reasonix.io</vendor_url>
|
||||
|
||||
<action id="io.reasonix.desktop.update">
|
||||
<description gettext-domain="reasonix-desktop">Install a Reasonix desktop update</description>
|
||||
<description xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 桌面端更新</description>
|
||||
<description xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 桌面端更新</description>
|
||||
<message gettext-domain="reasonix-desktop">Authentication is required to install a Reasonix update</message>
|
||||
<message xml:lang="zh_CN" gettext-domain="reasonix-desktop">安装 Reasonix 更新需要管理员授权</message>
|
||||
<message xml:lang="zh_TW" gettext-domain="reasonix-desktop">安裝 Reasonix 更新需要管理員授權</message>
|
||||
<defaults>
|
||||
<allow_any>no</allow_any>
|
||||
<allow_inactive>no</allow_inactive>
|
||||
<allow_active>auth_admin</allow_active>
|
||||
</defaults>
|
||||
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/reasonix/reasonix-update-helper</annotate>
|
||||
<annotate key="org.freedesktop.policykit.exec.argv1">install</annotate>
|
||||
</action>
|
||||
</policyconfig>
|
||||
@@ -1,98 +0,0 @@
|
||||
# nfpm config for the Linux .deb package. scripts/desktop-build.sh's linux branch
|
||||
# runs `nfpm package` after `wails build`, from the desktop/ dir (so the src paths
|
||||
# below are relative to desktop/). Portable clients keep using the .tar.gz under
|
||||
# platforms[]; this .deb is the native package channel (native_packages) and also
|
||||
# remains a human-download artifact.
|
||||
#
|
||||
# $DEB_VERSION and $DEB_ARCH are exported by desktop-build.sh. DEB_VERSION follows
|
||||
# Debian version ordering: stable X.Y.Z, prereleases as X.Y.Z~rc.N / X.Y.Z~canary.N
|
||||
# so they sort below the matching stable package. DEB_ARCH is the Go arch name,
|
||||
# which already matches Debian's (amd64, arm64).
|
||||
name: "reasonix-desktop"
|
||||
arch: "${DEB_ARCH}"
|
||||
platform: "linux"
|
||||
version: "${DEB_VERSION}"
|
||||
section: "utils"
|
||||
priority: "optional"
|
||||
maintainer: "esengine <[email protected]>"
|
||||
description: |
|
||||
Reasonix desktop — a Wails shell around the Go kernel.
|
||||
vendor: "Reasonix Contributors"
|
||||
homepage: "https://github.com/esengine/DeepSeek-Reasonix"
|
||||
license: "MIT"
|
||||
scripts:
|
||||
postinstall: ./build/linux/postinstall.sh
|
||||
# Runtime libraries the WebKitGTK 4.1 build links against (-tags webkit2_41); apt
|
||||
# pulls them in on install. These package names ship from Ubuntu 22.04 / Debian 12 on.
|
||||
# pkexec is required for the in-app Polkit authorization update path.
|
||||
depends:
|
||||
- libgtk-3-0
|
||||
- libwebkit2gtk-4.1-0
|
||||
- pkexec
|
||||
contents:
|
||||
- src: ./build/bin/reasonix-desktop
|
||||
dst: /usr/bin/reasonix-desktop
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix-launcher
|
||||
dst: /usr/bin/reasonix-launcher
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix
|
||||
dst: /usr/bin/reasonix
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/bin/reasonix-update-helper
|
||||
dst: /usr/lib/reasonix/reasonix-update-helper
|
||||
file_info:
|
||||
mode: 0755
|
||||
- src: ./build/linux/io.reasonix.desktop.update.policy
|
||||
dst: /usr/share/polkit-1/actions/io.reasonix.desktop.update.policy
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/reasonix.desktop
|
||||
dst: /usr/share/applications/reasonix.desktop
|
||||
file_info:
|
||||
mode: 0644
|
||||
# appicon.png is 1024x1024 — not a standard hicolor size dir, so install it under
|
||||
# the size-agnostic pixmaps path that the .desktop's `Icon=reasonix-desktop` finds.
|
||||
- src: ./build/appicon.png
|
||||
dst: /usr/share/pixmaps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/16x16/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/16x16/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/24x24/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/24x24/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/32x32/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/32x32/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/48x48/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/48x48/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/64x64/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/64x64/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/128x128/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/128x128/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/256x256/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/256x256/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/512x512/apps/reasonix-desktop.png
|
||||
dst: /usr/share/icons/hicolor/512x512/apps/reasonix-desktop.png
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: ./build/linux/icons/hicolor/scalable/apps/reasonix-desktop.svg
|
||||
dst: /usr/share/icons/hicolor/scalable/apps/reasonix-desktop.svg
|
||||
file_info:
|
||||
mode: 0644
|
||||
@@ -1,14 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Refresh desktop integration after both fresh installs and upgrades. Debian's
|
||||
# package triggers usually do this too, but an explicit best-effort refresh
|
||||
# repairs stale blank icons on desktops that kept an older cache.
|
||||
|
||||
if [ -x /usr/bin/gtk-update-icon-cache ]; then
|
||||
/usr/bin/gtk-update-icon-cache --force --quiet /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
if [ -x /usr/bin/update-desktop-database ]; then
|
||||
/usr/bin/update-desktop-database --quiet /usr/share/applications || true
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -1,9 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Reasonix
|
||||
Comment=Reasonix desktop — a Wails shell around the Go kernel
|
||||
Exec=reasonix-launcher
|
||||
Icon=reasonix-desktop
|
||||
Categories=Development;Utility;
|
||||
Terminal=false
|
||||
StartupWMClass=reasonix-desktop
|
||||
@@ -1,510 +0,0 @@
|
||||
Unicode true
|
||||
|
||||
####
|
||||
## Reasonix per-user NSIS installer.
|
||||
##
|
||||
## This file is COMMITTED and customized (Wails leaves an existing project.nsi
|
||||
## untouched and only regenerates wails_tools.nsh). The customizations vs.
|
||||
## Wails' default template:
|
||||
##
|
||||
## 1. REQUEST_EXECUTION_LEVEL "user" + InstallDir under $LOCALAPPDATA - install
|
||||
## without administrator rights. This lets the auto-updater re-run a freshly
|
||||
## downloaded installer in a visible progress-only mode with no UAC prompt.
|
||||
## 2. Uninstall registry under HKCU (not HKLM). Wails' wails.writeUninstaller /
|
||||
## wails.deleteUninstaller macros hard-code HKLM, which a non-admin install
|
||||
## cannot write - so we inline HKCU versions below instead.
|
||||
## 3. InstallDir is remembered across updates via InstallDirRegKey +
|
||||
## InstallLocation (HKCU\...\Uninstall\InstallLocation). When upgrading from
|
||||
## a build that did not write InstallLocation yet, .onInit falls back to the
|
||||
## old DisplayIcon path before using the default. Without this, every release
|
||||
## forces the user back to %LOCALAPPDATA%\Programs\Reasonix even if they had
|
||||
## moved the install to a different drive (e.g. D:\Tools\Reasonix); the
|
||||
## auto-updater would overwrite the wrong dir, leaving the old install
|
||||
## orphaned.
|
||||
##
|
||||
## Everything else mirrors Wails' generated default. Defines below override the
|
||||
## ProjectInfo values that wails_tools.nsh would otherwise populate.
|
||||
####
|
||||
|
||||
## Install per-user (no admin). Must be defined BEFORE including wails_tools.nsh,
|
||||
## which only sets the "admin" default when REQUEST_EXECUTION_LEVEL is undefined.
|
||||
!define REQUEST_EXECUTION_LEVEL "user"
|
||||
|
||||
####
|
||||
## Include the wails tools (auto-generated; provides INFO_* defines and the
|
||||
## wails.* macros used below).
|
||||
####
|
||||
!include "wails_tools.nsh"
|
||||
!include "FileFunc.nsh"
|
||||
!include "LogicLib.nsh"
|
||||
|
||||
# The build script writes this host-specific include before invoking makensis.
|
||||
# Keep a Windows fallback so opening this script directly still behaves like a
|
||||
# native Windows build.
|
||||
!if /FileExists "reasonix_host.nsh"
|
||||
!include "reasonix_host.nsh"
|
||||
!endif
|
||||
!ifndef REASONIX_UNINST_FINALIZE
|
||||
!define REASONIX_UNINST_FINALIZE 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'
|
||||
!endif
|
||||
|
||||
# The version information for this two must consist of 4 parts
|
||||
VIProductVersion "${INFO_PRODUCTVERSION}.0"
|
||||
VIFileVersion "${INFO_PRODUCTVERSION}.0"
|
||||
|
||||
VIAddVersionKey "CompanyName" "${INFO_COMPANYNAME}"
|
||||
VIAddVersionKey "FileDescription" "${INFO_PRODUCTNAME} Installer"
|
||||
VIAddVersionKey "ProductVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "FileVersion" "${INFO_PRODUCTVERSION}"
|
||||
VIAddVersionKey "LegalCopyright" "${INFO_COPYRIGHT}"
|
||||
VIAddVersionKey "ProductName" "${INFO_PRODUCTNAME}"
|
||||
|
||||
# Enable HiDPI support. https://nsis.sourceforge.io/Reference/ManifestDPIAware
|
||||
ManifestDPIAware true
|
||||
|
||||
!include "MUI.nsh"
|
||||
|
||||
!define MUI_ICON "..\icon.ico"
|
||||
!define MUI_UNICON "..\icon.ico"
|
||||
# !define MUI_WELCOMEFINISHPAGE_BITMAP "resources\leftimage.bmp" #Include this to add a bitmap on the left side of the Welcome Page. Must be a size of 164x314
|
||||
!define MUI_FINISHPAGE_NOAUTOCLOSE # Wait on the INSTFILES page so the user can take a look into the details of the installation steps
|
||||
!define MUI_ABORTWARNING # This will warn the user if they exit from the installer.
|
||||
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
|
||||
!insertmacro MUI_PAGE_WELCOME # Welcome to the installer page.
|
||||
# !insertmacro MUI_PAGE_LICENSE "resources\eula.txt" # Adds a EULA page to the installer
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipSetupPageForUpdate
|
||||
!insertmacro MUI_PAGE_DIRECTORY # In which folder install page.
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_SHOW reasonix.showUpdateProgress
|
||||
!insertmacro MUI_PAGE_INSTFILES # Installing page.
|
||||
!define MUI_PAGE_CUSTOMFUNCTION_PRE reasonix.skipFinishPageForUpdate
|
||||
!insertmacro MUI_PAGE_FINISH # Finished installation page.
|
||||
|
||||
!insertmacro MUI_UNPAGE_INSTFILES # Uinstalling page
|
||||
|
||||
!insertmacro MUI_LANGUAGE "English"
|
||||
!insertmacro MUI_LANGUAGE "SimpChinese"
|
||||
!insertmacro MUI_LANGUAGE "TradChinese"
|
||||
|
||||
LangString reasonixUpdateTitle ${LANG_ENGLISH} "Updating Reasonix"
|
||||
LangString reasonixUpdateTitle ${LANG_SIMPCHINESE} "正在更新 Reasonix"
|
||||
LangString reasonixUpdateTitle ${LANG_TRADCHINESE} "正在更新 Reasonix"
|
||||
LangString reasonixUpdateSubtitle ${LANG_ENGLISH} "Installing the verified update. Reasonix will restart automatically."
|
||||
LangString reasonixUpdateSubtitle ${LANG_SIMPCHINESE} "正在安装已验证的更新,完成后 Reasonix 将自动重启。"
|
||||
LangString reasonixUpdateSubtitle ${LANG_TRADCHINESE} "正在安裝已驗證的更新,完成後 Reasonix 將自動重新啟動。"
|
||||
|
||||
## Preserve the first-pass generated uninstaller so the release workflow can
|
||||
## Authenticode-sign it together with the other installed payload files.
|
||||
## The second pass provides ARG_REASONIX_SIGNED_UNINSTALLER and embeds that
|
||||
## signed binary instead of generating another unsigned uninstaller.
|
||||
!ifndef ARG_REASONIX_SIGNED_UNINSTALLER
|
||||
!uninstfinalize '${REASONIX_UNINST_FINALIZE}'
|
||||
!endif
|
||||
#!finalize 'signtool --file "%1"'
|
||||
|
||||
Name "${INFO_PRODUCTNAME}"
|
||||
OutFile "..\..\bin\${INFO_PROJECTNAME}-${ARCH}-installer.exe" # Name of the installer's file.
|
||||
!define REASONIX_DEFAULT_INSTALLDIR "$LOCALAPPDATA\Programs\${INFO_PRODUCTNAME}"
|
||||
!define REASONIX_UPDATE_HELPER "reasonix-update-helper.exe"
|
||||
!define REASONIX_GUARD "reasonix-guard.exe"
|
||||
!define REASONIX_LAUNCHER "reasonix-launcher.exe"
|
||||
!define REASONIX_CLI "reasonix-cli.exe"
|
||||
!define REASONIX_PORTABLE_ENTRY "Reasonix.exe"
|
||||
!define REASONIX_LAYOUT_INSTALLER "reasonix-layout-installer.exe"
|
||||
!define REASONIX_PAYLOAD_MANIFEST "reasonix-payload.json"
|
||||
!define REASONIX_PAYLOAD_SIGNATURE "reasonix-payload.json.minisig"
|
||||
!define REASONIX_LEGACY_UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\Reasonix"
|
||||
!define REASONIX_LEGACY_PRODUCT_KEY "Software\reasonix\Reasonix"
|
||||
!define REASONIX_UNLOCK_RETRIES 60
|
||||
Var ReasonixUpdateMode
|
||||
Var ReasonixStageMode
|
||||
InstallDirRegKey HKCU "${UNINST_KEY}" "InstallLocation" # Reuse the previous install path on update; .onInit falls back to the default on first install.
|
||||
InstallDir "${REASONIX_DEFAULT_INSTALLDIR}" # Per-user install location (no admin rights required).
|
||||
ShowInstDetails show # This will always show the installation details.
|
||||
|
||||
####
|
||||
## Per-user uninstaller registry (HKCU). Replaces wails.writeUninstaller /
|
||||
## wails.deleteUninstaller, which write HKLM and would fail without admin rights.
|
||||
####
|
||||
!macro reasonix.writeUninstaller
|
||||
!ifdef ARG_REASONIX_SIGNED_UNINSTALLER
|
||||
File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"
|
||||
!else
|
||||
WriteUninstaller "$INSTDIR\uninstall.exe"
|
||||
!endif
|
||||
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "Publisher" "${INFO_COMPANYNAME}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayName" "${INFO_PRODUCTNAME}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayVersion" "${INFO_PRODUCTVERSION}"
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${REASONIX_LAUNCHER}"
|
||||
!else
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "DisplayIcon" "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
!endif
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
|
||||
# Persist the resolved install path so a subsequent update picks it up
|
||||
# via InstallDirRegKey above. Without this, every release would force the
|
||||
# user back to %LOCALAPPDATA%\Programs\Reasonix even if they had moved
|
||||
# the install to a different drive (e.g. D:\Tools\Reasonix). The auto-
|
||||
# updater trusts this persisted path, so it has to be present before the
|
||||
# visible progress-only re-install.
|
||||
WriteRegStr HKCU "${UNINST_KEY}" "InstallLocation" "$INSTDIR"
|
||||
|
||||
${GetSize} "$INSTDIR" "/S=0K" $0 $1 $2
|
||||
IntFmt $0 "0x%08X" $0
|
||||
WriteRegDWORD HKCU "${UNINST_KEY}" "EstimatedSize" "$0"
|
||||
!macroend
|
||||
|
||||
; Tauri 0.53 separately persisted $INSTDIR under its manufacturer/product key
|
||||
; and restores that value before every later install. Clear only a same-root
|
||||
; value so re-running 0.53 cannot overwrite the current uninstaller; preserve a
|
||||
; genuinely separate legacy installation. If this cleanup fails, retain the old
|
||||
; uninstall alias so a later update can retry the migration.
|
||||
!macro reasonix.deleteLegacyInstallerStateIfOwned
|
||||
StrCpy $1 "1"
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
|
||||
${If} $0 == "$INSTDIR"
|
||||
ClearErrors
|
||||
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
|
||||
${If} ${Errors}
|
||||
StrCpy $1 "0"
|
||||
${EndIf}
|
||||
${ElseIf} $0 == "$\"$INSTDIR$\""
|
||||
ClearErrors
|
||||
DeleteRegValue HKCU "${REASONIX_LEGACY_PRODUCT_KEY}" ""
|
||||
${If} ${Errors}
|
||||
StrCpy $1 "0"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
|
||||
${If} $1 == "1"
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
|
||||
${If} $0 == "$INSTDIR"
|
||||
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
|
||||
${ElseIf} $0 == "$\"$INSTDIR$\""
|
||||
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
|
||||
${Else}
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
|
||||
${If} $0 == "$INSTDIR\uninstall.exe"
|
||||
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
|
||||
${ElseIf} $0 == "$\"$INSTDIR\uninstall.exe$\""
|
||||
DeleteRegKey HKCU "${REASONIX_LEGACY_UNINST_KEY}"
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
${EndIf}
|
||||
!macroend
|
||||
|
||||
!macro reasonix.deleteUninstaller
|
||||
Delete "$INSTDIR\uninstall.exe"
|
||||
DeleteRegKey HKCU "${UNINST_KEY}"
|
||||
!macroend
|
||||
|
||||
Function .onInit
|
||||
!insertmacro wails.checkArchitecture
|
||||
|
||||
; The helper passes /REASONIXUPDATE=1 and a final /D=<current directory>.
|
||||
; This mode remains visible but skips every page that could change the
|
||||
; destination, then closes automatically after the file copy so the helper
|
||||
; can relaunch Reasonix. A normal manual installer keeps the full wizard.
|
||||
StrCpy $ReasonixUpdateMode "0"
|
||||
StrCpy $ReasonixStageMode "0"
|
||||
${GetParameters} $R0
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/REASONIXUPDATE=" $R1
|
||||
IfErrors reasonix_update_mode_done
|
||||
StrCmp $R1 "1" 0 reasonix_update_mode_done
|
||||
StrCpy $ReasonixUpdateMode "1"
|
||||
|
||||
reasonix_update_mode_done:
|
||||
ClearErrors
|
||||
${GetOptions} $R0 "/REASONIXSTAGE=" $R2
|
||||
IfErrors reasonix_stage_mode_done
|
||||
StrCmp $R2 "1" 0 reasonix_stage_mode_done
|
||||
StrCpy $ReasonixStageMode "1"
|
||||
|
||||
reasonix_stage_mode_done:
|
||||
|
||||
; InstallDirRegKey leaves $INSTDIR empty when the InstallLocation value is
|
||||
; missing. Older installers still wrote DisplayIcon, so use its parent folder
|
||||
; as a compatibility bridge before falling back to the per-user default.
|
||||
StrCmp $INSTDIR "" 0 done
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${UNINST_KEY}" "DisplayIcon"
|
||||
IfErrors legacy_location
|
||||
StrCmp $0 "" legacy_location
|
||||
${GetParent} "$0" $INSTDIR
|
||||
StrCmp $INSTDIR "" legacy_location done
|
||||
|
||||
legacy_location:
|
||||
; Tauri 0.53 used a different uninstall key and may have stored the selected
|
||||
; directory with surrounding quotes (for example "D:\Reasonix"). Reuse it
|
||||
; only while its uninstaller still exists so a stale registry value cannot
|
||||
; redirect the repair installer into an unrelated directory.
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "InstallLocation"
|
||||
IfErrors legacy_uninstaller
|
||||
StrCmp $0 "" legacy_uninstaller
|
||||
StrCpy $1 $0 1
|
||||
StrCmp $1 "$\"" 0 legacy_location_ready
|
||||
StrCpy $1 $0 1 -1
|
||||
StrCmp $1 "$\"" 0 legacy_location_ready
|
||||
StrCpy $0 $0 -1 1
|
||||
|
||||
legacy_location_ready:
|
||||
IfFileExists "$0\uninstall.exe" 0 legacy_uninstaller
|
||||
StrCpy $INSTDIR $0
|
||||
Goto done
|
||||
|
||||
legacy_uninstaller:
|
||||
ClearErrors
|
||||
ReadRegStr $0 HKCU "${REASONIX_LEGACY_UNINST_KEY}" "UninstallString"
|
||||
IfErrors fallback
|
||||
StrCmp $0 "" fallback
|
||||
StrCpy $1 $0 1
|
||||
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
|
||||
StrCpy $1 $0 1 -1
|
||||
StrCmp $1 "$\"" 0 legacy_uninstaller_ready
|
||||
StrCpy $0 $0 -1 1
|
||||
|
||||
legacy_uninstaller_ready:
|
||||
IfFileExists "$0" 0 fallback
|
||||
${GetParent} "$0" $INSTDIR
|
||||
StrCmp $INSTDIR "" fallback done
|
||||
|
||||
fallback:
|
||||
StrCpy $INSTDIR "${REASONIX_DEFAULT_INSTALLDIR}"
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.skipSetupPageForUpdate
|
||||
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_setup_page
|
||||
Abort
|
||||
|
||||
reasonix_show_setup_page:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.showUpdateProgress
|
||||
StrCmp $ReasonixUpdateMode "1" 0 reasonix_update_progress_done
|
||||
!insertmacro MUI_HEADER_TEXT "$(reasonixUpdateTitle)" "$(reasonixUpdateSubtitle)"
|
||||
SetDetailsView hide
|
||||
SetAutoClose true
|
||||
BringToFront
|
||||
|
||||
reasonix_update_progress_done:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.skipFinishPageForUpdate
|
||||
StrCmp $ReasonixUpdateMode "1" 0 reasonix_show_finish_page
|
||||
Abort
|
||||
|
||||
reasonix_show_finish_page:
|
||||
FunctionEnd
|
||||
|
||||
Function reasonix.waitForExecutableUnlock
|
||||
StrCpy $0 0
|
||||
|
||||
retry:
|
||||
IfFileExists "$INSTDIR\${PRODUCT_EXECUTABLE}" 0 check_versioned_target
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_versioned_target:
|
||||
; A same-version recovery install replaces this directory transactionally.
|
||||
; Detect the running active binary before asking the Go activator to rename it.
|
||||
IfFileExists "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0 check_guard
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_guard:
|
||||
IfFileExists "$INSTDIR\${REASONIX_GUARD}" 0 check_launcher
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${REASONIX_GUARD}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_launcher:
|
||||
IfFileExists "$INSTDIR\${REASONIX_LAUNCHER}" 0 check_cli
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${REASONIX_LAUNCHER}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_cli:
|
||||
IfFileExists "$INSTDIR\${REASONIX_CLI}" 0 check_portable_entry
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${REASONIX_CLI}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
|
||||
check_portable_entry:
|
||||
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 done
|
||||
ClearErrors
|
||||
FileOpen $1 "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" a
|
||||
IfErrors locked
|
||||
FileClose $1
|
||||
Goto done
|
||||
|
||||
locked:
|
||||
IntOp $0 $0 + 1
|
||||
IntCmp $0 ${REASONIX_UNLOCK_RETRIES} failed 0 0
|
||||
Sleep 1000
|
||||
Goto retry
|
||||
|
||||
failed:
|
||||
IfSilent silent interactive
|
||||
|
||||
interactive:
|
||||
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "Reasonix is still running. Close Reasonix, then click Retry to continue the installation." IDRETRY retry IDCANCEL abort
|
||||
Goto retry
|
||||
|
||||
silent:
|
||||
SetErrorLevel 1618
|
||||
|
||||
abort:
|
||||
Abort "Reasonix is still running. Close Reasonix and run the installer again."
|
||||
|
||||
done:
|
||||
FunctionEnd
|
||||
|
||||
Section
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
|
||||
; the new helper's staging extract). Do not write shortcuts/uninstaller.
|
||||
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
|
||||
; with a permanent thin launcher at InstallRoot. Guard is only present in
|
||||
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
|
||||
; a normal install.
|
||||
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
|
||||
!insertmacro wails.webview2runtime
|
||||
Call reasonix.waitForExecutableUnlock
|
||||
Goto reasonix_normal_install
|
||||
|
||||
reasonix_stage_payload:
|
||||
SetOutPath $INSTDIR
|
||||
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
|
||||
!endif
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_GUARD}"
|
||||
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_CLI}"
|
||||
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
|
||||
!endif
|
||||
Goto reasonix_section_done
|
||||
|
||||
reasonix_normal_install:
|
||||
; Extract into an install-local temporary directory, then let the signed Go
|
||||
; activator validate the complete release unit, transactionally publish the
|
||||
; version/root entries, and strictly atomically replace current.json last.
|
||||
; The normal/recovery installer therefore shares the same commit protocol as
|
||||
; automatic updates instead of writing live files or current.json in place.
|
||||
System::Call 'kernel32::GetCurrentProcessId() i .R8'
|
||||
CreateDirectory "$INSTDIR\versions"
|
||||
StrCpy $R9 "$INSTDIR\versions\.installer-v${INFO_PRODUCTVERSION}-$R8"
|
||||
RMDir /r "$R9"
|
||||
CreateDirectory "$R9"
|
||||
SetOutPath "$R9"
|
||||
!insertmacro wails.files
|
||||
!if /FileExists "${REASONIX_UPDATE_HELPER}"
|
||||
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
|
||||
!else
|
||||
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_CLI}"
|
||||
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
|
||||
!else
|
||||
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
|
||||
!endif
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
|
||||
!endif
|
||||
|
||||
SetOutPath "$PLUGINSDIR"
|
||||
!if /FileExists "${REASONIX_GUARD}"
|
||||
File "/oname=${REASONIX_LAYOUT_INSTALLER}" "${REASONIX_GUARD}"
|
||||
!else
|
||||
!error "${REASONIX_GUARD} was not found; normal installs require the signed layout activator."
|
||||
!endif
|
||||
DetailPrint "Reasonix layout activator output:"
|
||||
nsExec::ExecToLog /OEM '"$PLUGINSDIR\${REASONIX_LAYOUT_INSTALLER}" --install-root "$INSTDIR" --version "v${INFO_PRODUCTVERSION}" --activate-staging "$R9" --no-relaunch'
|
||||
Pop $0
|
||||
StrCmp $0 "0" reasonix_layout_activated
|
||||
DetailPrint "Reasonix layout activation failed with exit code $0; the previous version remains active."
|
||||
RMDir /r "$R9"
|
||||
SetErrorLevel 1
|
||||
Abort "Reasonix could not activate the verified release. The previous version was left unchanged."
|
||||
|
||||
reasonix_layout_activated:
|
||||
RMDir /r "$R9"
|
||||
SetOutPath "$INSTDIR"
|
||||
|
||||
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_GUARD}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
|
||||
!if /FileExists "${REASONIX_LAUNCHER}"
|
||||
; Keep both target and icon on the stable launcher. Pointing IconLocation at
|
||||
; versions\vX\reasonix-desktop.exe leaves a blank shortcut as soon as version
|
||||
; retention removes that directory after a later update.
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\${REASONIX_LAUNCHER}" 0
|
||||
!else
|
||||
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
|
||||
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
|
||||
!endif
|
||||
|
||||
!insertmacro wails.associateFiles
|
||||
!insertmacro wails.associateCustomProtocols
|
||||
!insertmacro reasonix.writeUninstaller
|
||||
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
|
||||
|
||||
reasonix_section_done:
|
||||
SectionEnd
|
||||
|
||||
Section "uninstall"
|
||||
!insertmacro wails.setShellContext
|
||||
|
||||
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
|
||||
|
||||
; Precision uninstall: flat leftovers, thin entry points, and version trees.
|
||||
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
|
||||
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
|
||||
Delete "$INSTDIR\${REASONIX_GUARD}"
|
||||
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
|
||||
Delete "$INSTDIR\${REASONIX_CLI}"
|
||||
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
|
||||
Delete "$INSTDIR\current.json"
|
||||
RMDir /r "$INSTDIR\versions"
|
||||
|
||||
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
|
||||
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
|
||||
|
||||
!insertmacro wails.unassociateFiles
|
||||
!insertmacro wails.unassociateCustomProtocols
|
||||
|
||||
!insertmacro reasonix.deleteUninstaller
|
||||
!insertmacro reasonix.deleteLegacyInstallerStateIfOwned
|
||||
|
||||
; Only remove the installation directory if it is empty to prevent data loss
|
||||
RMDir $INSTDIR
|
||||
SectionEnd
|
||||
@@ -1,36 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDesktopBuildLinksSharedSourceRevision(t *testing.T) {
|
||||
// The remote protocol source-revision ldflag was removed with the Remote
|
||||
// Workbench protocol stack; the test now asserts the shared product docs
|
||||
// identity and the packaging-order invariant below.
|
||||
data, err := os.ReadFile("../scripts/desktop-build.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
script := string(data)
|
||||
for _, want := range []string{
|
||||
`SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`,
|
||||
`SOURCE_REVISION="$SOURCE_REVISION+dirty"`,
|
||||
`product_docs_ldflags="-X reasonix/internal/productdocs.linkedVersion=$VERSION -X reasonix/internal/productdocs.linkedRevision=$SOURCE_REVISION"`,
|
||||
`cli_identity_ldflags="-X main.version=$VERSION -X main.gitCommit=$GIT_COMMIT -X main.buildTimeUTC=$BUILD_TIME_UTC $product_docs_ldflags"`,
|
||||
`ldflags="-X main.version=$VERSION -X main.channel=$CHANNEL $product_docs_ldflags"`,
|
||||
`cli_identity_ldflags`,
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Fatalf("desktop-build.sh does not preserve the shared build identity %q", want)
|
||||
}
|
||||
}
|
||||
|
||||
revisionIndex := strings.Index(script, `SOURCE_REVISION="$(git -C "$ROOT" rev-parse --verify HEAD)"`)
|
||||
packagingMutationIndex := strings.Index(script, `node -e 'const fs=require("fs")`)
|
||||
if revisionIndex < 0 || packagingMutationIndex < 0 || revisionIndex >= packagingMutationIndex {
|
||||
t.Fatal("desktop-build.sh must capture the source revision before mutating packaging metadata")
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reasonix/internal/capdiag"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
// CapabilityDiagnostics returns a read-only capability report for the active
|
||||
// workspace. When includeSessionRuntime is true, connected/failed/deferred
|
||||
// status is merged from the active tab Host only — no new MCP processes are
|
||||
// started, and no controller rebuild or session snapshot is triggered.
|
||||
func (a *App) CapabilityDiagnostics(includeSessionRuntime bool) capdiag.Report {
|
||||
root, host := a.activeDiagSnapshot(includeSessionRuntime)
|
||||
opts := capdiag.Options{Root: root, Live: false}
|
||||
|
||||
if !includeSessionRuntime {
|
||||
return capdiag.Collect(opts)
|
||||
}
|
||||
|
||||
opts.RuntimeHost = host
|
||||
if host == nil {
|
||||
return capdiag.CollectWithRuntimeUnavailable(opts)
|
||||
}
|
||||
return capdiag.Collect(opts)
|
||||
}
|
||||
|
||||
// activeDiagSnapshot reads workspace root and optional Host under one lock so
|
||||
// a tab switch cannot pair project A's config with project B's runtime Host.
|
||||
func (a *App) activeDiagSnapshot(includeHost bool) (root string, host *plugin.Host) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
tab := a.activeTabLocked()
|
||||
if tab == nil {
|
||||
return "", nil
|
||||
}
|
||||
root = tab.WorkspaceRoot
|
||||
if includeHost && tab.Ctrl != nil {
|
||||
host = tab.Ctrl.Host()
|
||||
}
|
||||
return root, host
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/plugin"
|
||||
)
|
||||
|
||||
func TestCapabilityDiagnosticsStaticUsesWorkspaceRoot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("# workspace agents\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.mu.Lock()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "diag",
|
||||
Scope: "project",
|
||||
WorkspaceRoot: root,
|
||||
Ready: true,
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{"diag": tab}
|
||||
app.tabOrder = []string{"diag"}
|
||||
app.activeTabID = "diag"
|
||||
app.mu.Unlock()
|
||||
|
||||
report := app.CapabilityDiagnostics(false)
|
||||
if report.Live {
|
||||
t.Fatal("static diagnostics must not set live")
|
||||
}
|
||||
if report.SchemaVersion != 1 {
|
||||
t.Fatalf("schema = %d", report.SchemaVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDiagnosticsRuntimeUsesActiveHost(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
host := plugin.NewHost()
|
||||
ctrl := control.New(control.Options{Host: host})
|
||||
app.setTestCtrl(ctrl, "test-model")
|
||||
|
||||
report := app.CapabilityDiagnostics(true)
|
||||
if report.SchemaVersion != 1 {
|
||||
t.Fatalf("schema = %d", report.SchemaVersion)
|
||||
}
|
||||
_ = app.CapabilityDiagnostics(true)
|
||||
if ctrl.Host() != host {
|
||||
t.Fatal("diagnostics must reuse the same Host pointer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDiagnosticsAtomicSnapshot(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
host := plugin.NewHost()
|
||||
ctrl := control.New(control.Options{Host: host})
|
||||
root := t.TempDir()
|
||||
app.mu.Lock()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"t1": {
|
||||
ID: "t1", Scope: "project", WorkspaceRoot: root, Ready: true,
|
||||
Ctrl: ctrl, disabledMCP: map[string]ServerView{},
|
||||
},
|
||||
}
|
||||
app.tabOrder = []string{"t1"}
|
||||
app.activeTabID = "t1"
|
||||
app.mu.Unlock()
|
||||
|
||||
gotRoot, gotHost := app.activeDiagSnapshot(true)
|
||||
if gotRoot != root {
|
||||
t.Fatalf("root = %q, want %q", gotRoot, root)
|
||||
}
|
||||
if gotHost != host {
|
||||
t.Fatal("host mismatch from atomic snapshot")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapabilityDiagnosticsRuntimeUnavailableWithoutTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
// Explicitly empty tabs.
|
||||
app.mu.Lock()
|
||||
app.tabs = map[string]*WorkspaceTab{}
|
||||
app.tabOrder = nil
|
||||
app.activeTabID = ""
|
||||
app.mu.Unlock()
|
||||
|
||||
report := app.CapabilityDiagnostics(true)
|
||||
found := false
|
||||
for _, is := range report.Issues {
|
||||
if is.Code == "mcp.runtime_unavailable" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected mcp.runtime_unavailable, issues=%+v", report.Issues)
|
||||
}
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/checkpoint"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
func TestDesktopRewindCommitAndUndoUseAuthoritativeControllerState(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
root := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
filePath := filepath.Join(root, "a.txt")
|
||||
if err := os.WriteFile(filePath, []byte("after"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fileInfo, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diskMode := uint32(fileInfo.Mode().Perm())
|
||||
before := "before"
|
||||
afterExists := true
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{
|
||||
SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: time.Now(), Prompt: "edit", MsgIndex: 3,
|
||||
Coverage: checkpoint.CoverageComplete,
|
||||
Files: []checkpoint.FileSnap{{
|
||||
Path: "a.txt", Content: &before, SHA256: checkpoint.Digest([]byte(before)), Mode: diskMode,
|
||||
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("after")), AfterMode: diskMode,
|
||||
CaptureSource: checkpoint.CaptureBeforeMutation,
|
||||
}},
|
||||
})
|
||||
session := agent.NewSession("")
|
||||
session.Replace([]provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "first"},
|
||||
{Role: provider.RoleAssistant, Content: "answer"},
|
||||
{Role: provider.RoleUser, Content: "edit"},
|
||||
{Role: provider.RoleAssistant, Content: "done"},
|
||||
})
|
||||
if err := session.Save(sessionPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ag := agent.New(nil, nil, session, agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, Runner: ag, SessionDir: dir, SessionPath: sessionPath, WorkspaceRoot: root, Label: "test"})
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
plan := app.PreviewRewindForTab("test", 1, "both")
|
||||
if !plan.OK || !plan.CanFiles || !plan.CanConversation {
|
||||
t.Fatalf("preview = %+v", plan)
|
||||
}
|
||||
result := app.CommitRewindForTab("test", plan.PlanID, 1, "both")
|
||||
if !result.OK || !result.UndoAvailable || result.TransactionID == "" {
|
||||
t.Fatalf("commit = %+v", result)
|
||||
}
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != before {
|
||||
t.Fatalf("file after commit = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 3 {
|
||||
t.Fatalf("controller history after commit = %d, want 3", len(got))
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 3 {
|
||||
t.Fatalf("desktop history after commit = %d, want 3", len(got))
|
||||
}
|
||||
|
||||
undo := app.UndoRewindForTab("test", result.TransactionID)
|
||||
if !undo.OK {
|
||||
t.Fatalf("undo = %+v", undo)
|
||||
}
|
||||
if got, err := os.ReadFile(filePath); err != nil || string(got) != "after" {
|
||||
t.Fatalf("file after undo = %q err=%v", got, err)
|
||||
}
|
||||
if got := ctrl.History(); len(got) != 5 {
|
||||
t.Fatalf("controller history after undo = %d, want 5", len(got))
|
||||
}
|
||||
if got := app.HistoryForTab("test"); len(got) != 5 {
|
||||
t.Fatalf("desktop history after undo = %d, want 5", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func seedCheckpoint(t *testing.T, ckptDir string, c checkpoint.Checkpoint) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(ckptDir, "turn-"+strconv.Itoa(c.Turn)+".json"), b, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertCheckpointFilesEncodeAsArray(t *testing.T, metas []CheckpointMeta) {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(metas)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload []struct {
|
||||
Files json.RawMessage `json:"files"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, item := range payload {
|
||||
if string(item.Files) == "null" {
|
||||
t.Fatalf("checkpoint %d files encoded as null; frontend expects []", i)
|
||||
}
|
||||
if len(item.Files) == 0 || item.Files[0] != '[' {
|
||||
t.Fatalf("checkpoint %d files encoded as %s, want JSON array", i, item.Files)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckpointsCanCodePropagatesToEarlierTurns covers #3438: RestoreCode(turn)
|
||||
// reverts files touched in that turn or any later one, so a turn with no file
|
||||
// changes of its own can still rewind code when a later turn changed files. The
|
||||
// desktop CanCode flag must reflect that suffix capability, not just the turn's
|
||||
// own paths.
|
||||
func TestCheckpointsCanCodePropagatesToEarlierTurns(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
afterExists := true
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "ask only", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 1, Time: now, Prompt: "edit a file", MsgIndex: 2,
|
||||
Coverage: checkpoint.CoverageComplete,
|
||||
Files: []checkpoint.FileSnap{{
|
||||
Path: "a.txt", Content: &content, SHA256: checkpoint.Digest([]byte(content)),
|
||||
AfterExisted: &afterExists, AfterSHA256: checkpoint.Digest([]byte("new")), CaptureSource: checkpoint.CaptureBeforeMutation,
|
||||
}}})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 2, Time: now, Prompt: "ask again", MsgIndex: 4})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 3 {
|
||||
t.Fatalf("checkpoints = %d, want 3", len(metas))
|
||||
}
|
||||
got := map[int]bool{}
|
||||
for _, m := range metas {
|
||||
got[m.Turn] = m.CanCode
|
||||
}
|
||||
if !got[0] {
|
||||
t.Error("turn 0 (no files of its own) should allow code rewind — turn 1 changed files")
|
||||
}
|
||||
if !got[1] {
|
||||
t.Error("turn 1 changed files, should allow code rewind")
|
||||
}
|
||||
if got[2] {
|
||||
t.Error("turn 2 is after the last file-bearing turn, should NOT allow code rewind")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != 1 {
|
||||
t.Fatalf("turn 1 file count = %d, want 1 for this turn", metas[1].TurnFileCount)
|
||||
}
|
||||
if len(metas[0].Files) != 1 || metas[0].Files[0] != "a.txt" {
|
||||
t.Fatalf("turn 0 cumulative files = %#v, want [a.txt]", metas[0].Files)
|
||||
}
|
||||
if metas[0].FileCount != 1 || metas[0].FilesTruncated {
|
||||
t.Fatalf("turn 0 file summary = count %d truncated %v, want count 1 truncated false", metas[0].FileCount, metas[0].FilesTruncated)
|
||||
}
|
||||
if len(metas[2].Files) != 0 {
|
||||
t.Fatalf("turn 2 cumulative files = %#v, want empty", metas[2].Files)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
|
||||
func TestCheckpointsCanCodeDoesNotReenableLegacySuffix(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{SchemaVersion: checkpoint.SchemaV2, Turn: 0, Time: now, Prompt: "before", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "legacy edit", MsgIndex: 2,
|
||||
Files: []checkpoint.FileSnap{{Path: "a.txt", Content: &content}}})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
for _, meta := range metas {
|
||||
if meta.CanCode {
|
||||
t.Fatalf("legacy suffix re-enabled code rewind at turn %d: %+v", meta.Turn, meta)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointsForTabLimitsCumulativeFilePreview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sessionPath := filepath.Join(dir, "s.jsonl")
|
||||
ckptDir := sessionPath[:len(sessionPath)-len(".jsonl")] + ".ckpt"
|
||||
if err := os.MkdirAll(ckptDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := "old"
|
||||
files := make([]checkpoint.FileSnap, 0, checkpointFilePreviewLimit+5)
|
||||
for i := range checkpointFilePreviewLimit + 5 {
|
||||
files = append(files, checkpoint.FileSnap{Path: "file-" + strconv.Itoa(1000+i) + ".txt", Content: &content})
|
||||
}
|
||||
now := time.Now()
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 0, Time: now, Prompt: "before edits", MsgIndex: 0})
|
||||
seedCheckpoint(t, ckptDir, checkpoint.Checkpoint{Turn: 1, Time: now, Prompt: "edit many files", MsgIndex: 2, Files: files})
|
||||
|
||||
ag := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
|
||||
ctrl := control.New(control.Options{Executor: ag, SessionDir: dir, Label: "test"})
|
||||
ctrl.SetSessionPath(sessionPath)
|
||||
|
||||
app := &App{}
|
||||
app.setTestCtrl(ctrl, "test")
|
||||
|
||||
metas := app.CheckpointsForTab("test")
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("checkpoints = %d, want 2", len(metas))
|
||||
}
|
||||
if metas[0].FileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 0 cumulative file count = %d, want %d", metas[0].FileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
if len(metas[0].Files) != checkpointFilePreviewLimit {
|
||||
t.Fatalf("turn 0 preview files = %d, want %d", len(metas[0].Files), checkpointFilePreviewLimit)
|
||||
}
|
||||
if !metas[0].FilesTruncated {
|
||||
t.Fatal("turn 0 should mark file preview as truncated")
|
||||
}
|
||||
if metas[0].TurnFileCount != 0 {
|
||||
t.Fatalf("turn 0 file count = %d, want 0 for this turn", metas[0].TurnFileCount)
|
||||
}
|
||||
if metas[1].TurnFileCount != checkpointFilePreviewLimit+5 {
|
||||
t.Fatalf("turn 1 file count = %d, want %d", metas[1].TurnFileCount, checkpointFilePreviewLimit+5)
|
||||
}
|
||||
assertCheckpointFilesEncodeAsArray(t, metas)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestParseNumericVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestStampExecutableSupportsWindowsReleaseArchitectures(t *testing.T) {
|
||||
icon, err := filepath.Abs(filepath.Join("..", "..", "build", "windows", "icon.ico"))
|
||||
icon, err := filepath.Abs(filepath.Join("..", "..", "next", "build", "windows", "icon.ico"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package main
|
||||
|
||||
const configLoadWarningsEvent = "config:load-warnings"
|
||||
|
||||
func (a *App) nextConfigLoadWarningsRevision() uint64 {
|
||||
if a == nil {
|
||||
return 0
|
||||
}
|
||||
return a.runtimeEvents.configWarningsRevision.Add(1)
|
||||
}
|
||||
|
||||
func (a *App) configLoadWarningsHandler() func([]string) bool {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
revision := a.nextConfigLoadWarningsRevision()
|
||||
return func(warnings []string) bool {
|
||||
return a.emitConfigLoadWarnings(revision, warnings)
|
||||
}
|
||||
}
|
||||
|
||||
// emitConfigLoadWarnings transfers ownership of resilient-loader diagnostics
|
||||
// to the persistent desktop banner. Returning false keeps boot's diagnostic
|
||||
// notice when no Wails event context is available.
|
||||
func (a *App) emitConfigLoadWarnings(revision uint64, warnings []string) bool {
|
||||
if a == nil || a.ctx == nil || len(warnings) == 0 {
|
||||
return false
|
||||
}
|
||||
owned := append([]string(nil), warnings...)
|
||||
a.runtimeEvents.Emit(a.ctx, configLoadWarningsEvent, owned, revision)
|
||||
return true
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEmitConfigLoadWarningsRequiresContextAndOwnsPayload(t *testing.T) {
|
||||
if (&App{}).emitConfigLoadWarnings(1, []string{"warning"}) {
|
||||
t.Fatal("handler accepted warnings without a Wails context")
|
||||
}
|
||||
if (&App{ctx: context.Background()}).emitConfigLoadWarnings(1, nil) {
|
||||
t.Fatal("handler accepted an empty warning list")
|
||||
}
|
||||
|
||||
type emittedEvent struct {
|
||||
name string
|
||||
warnings []string
|
||||
revision uint64
|
||||
}
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
events := make(chan emittedEvent, 1)
|
||||
app := &App{ctx: context.Background()}
|
||||
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
|
||||
close(started)
|
||||
<-release
|
||||
warnings, _ := payload[0].([]string)
|
||||
revision, _ := payload[1].(uint64)
|
||||
events <- emittedEvent{name: name, warnings: warnings, revision: revision}
|
||||
}
|
||||
|
||||
warnings := []string{"user config is invalid"}
|
||||
if !app.emitConfigLoadWarnings(42, warnings) {
|
||||
t.Fatal("handler rejected warnings with a Wails context")
|
||||
}
|
||||
select {
|
||||
case <-started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime event emitter did not start")
|
||||
}
|
||||
warnings[0] = "mutated by caller"
|
||||
close(release)
|
||||
|
||||
select {
|
||||
case got := <-events:
|
||||
if got.name != configLoadWarningsEvent {
|
||||
t.Fatalf("event name = %q, want %q", got.name, configLoadWarningsEvent)
|
||||
}
|
||||
if got.revision != 42 {
|
||||
t.Fatalf("event revision = %d, want 42", got.revision)
|
||||
}
|
||||
if len(got.warnings) != 1 || got.warnings[0] != "user config is invalid" {
|
||||
t.Fatalf("event warnings = %v, want owned original payload", got.warnings)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime event was not delivered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigLoadWarningRevisionsFenceEventsStartedBeforeReload(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
app := &App{ctx: context.Background()}
|
||||
type warningEvent struct {
|
||||
warnings []string
|
||||
revision uint64
|
||||
}
|
||||
events := make(chan warningEvent, 2)
|
||||
app.runtimeEvents.emit = func(_ context.Context, _ string, payload ...any) {
|
||||
warnings, _ := payload[0].([]string)
|
||||
revision, _ := payload[1].(uint64)
|
||||
events <- warningEvent{warnings: warnings, revision: revision}
|
||||
}
|
||||
|
||||
oldBuildHandler := app.configLoadWarningsHandler()
|
||||
reloaded, err := app.ReloadUserConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("ReloadUserConfig: %v", err)
|
||||
}
|
||||
if reloaded.ConfigWarningsRevision == 0 {
|
||||
t.Fatal("reload did not return a config warning revision")
|
||||
}
|
||||
if !oldBuildHandler([]string{"stale warning"}) {
|
||||
t.Fatal("old build handler rejected warning")
|
||||
}
|
||||
newBuildHandler := app.configLoadWarningsHandler()
|
||||
if !newBuildHandler([]string{"new warning"}) {
|
||||
t.Fatal("new build handler rejected warning")
|
||||
}
|
||||
|
||||
receive := func() warningEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case payload := <-events:
|
||||
return payload
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime warning event was not delivered")
|
||||
return warningEvent{}
|
||||
}
|
||||
}
|
||||
oldPayload := receive()
|
||||
newPayload := receive()
|
||||
if len(oldPayload.warnings) != 1 || oldPayload.revision >= reloaded.ConfigWarningsRevision {
|
||||
t.Fatalf("old build event = %+v, reload barrier = %d", oldPayload, reloaded.ConfigWarningsRevision)
|
||||
}
|
||||
if len(newPayload.warnings) != 1 || newPayload.revision <= reloaded.ConfigWarningsRevision {
|
||||
t.Fatalf("new build event = %+v, reload barrier = %d", newPayload, reloaded.ConfigWarningsRevision)
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/billing"
|
||||
)
|
||||
|
||||
// ContextInfo is the prompt-vs-window gauge payload plus session totals. Used
|
||||
// and Window both zero means no context-window data yet.
|
||||
type ContextInfo struct {
|
||||
Used int `json:"used"`
|
||||
Window int `json:"window"`
|
||||
SessionTokens int `json:"sessionTokens"`
|
||||
CompactRatio float64 `json:"compactRatio,omitempty"`
|
||||
SessionCost float64 `json:"sessionCost,omitempty"`
|
||||
SessionCurrency string `json:"sessionCurrency,omitempty"`
|
||||
CacheHitTokens int `json:"cacheHitTokens,omitempty"`
|
||||
CacheMissTokens int `json:"cacheMissTokens,omitempty"`
|
||||
Estimated bool `json:"estimated,omitempty"`
|
||||
SessionCostComplete bool `json:"sessionCostComplete,omitempty"`
|
||||
SessionCostQuote *billing.CostQuote `json:"sessionCostQuote,omitempty"`
|
||||
Sources map[string]usageSourceStats `json:"sources,omitempty"`
|
||||
Maintenance *ContextMaintenanceInfo `json:"maintenance,omitempty"`
|
||||
}
|
||||
|
||||
// ContextMaintenanceInfo is the Wails-safe current-view snapshot. Optional
|
||||
// fields preserve compatibility with older desktop/front-end combinations.
|
||||
type ContextMaintenanceInfo struct {
|
||||
CanonicalTokens int `json:"canonicalTokens,omitempty"`
|
||||
ProjectedTokens int `json:"projectedTokens,omitempty"`
|
||||
SummaryTokens int `json:"summaryTokens,omitempty"`
|
||||
LastSavedTokens int `json:"lastSavedTokens,omitempty"`
|
||||
SnipTrigger int `json:"snipTrigger,omitempty"` // always 0; legacy compatibility
|
||||
FoldTrigger int `json:"foldTrigger,omitempty"` // alias of TriggerTokens
|
||||
ForceTrigger int `json:"forceTrigger,omitempty"` // always 0; legacy compatibility
|
||||
TriggerTokens int `json:"triggerTokens,omitempty"`
|
||||
CheckpointState string `json:"checkpointState,omitempty"` // none|restored|applied
|
||||
HardInputCeiling int `json:"hardInputCeiling,omitempty"`
|
||||
Headroom int `json:"headroom,omitempty"`
|
||||
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
|
||||
Blocked bool `json:"blocked,omitempty"`
|
||||
LastReceipt *ContextMaintenanceReceiptInfo `json:"lastReceipt,omitempty"`
|
||||
}
|
||||
|
||||
type ContextMaintenanceReceiptInfo struct {
|
||||
OperationID string `json:"operationId,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Trigger string `json:"trigger,omitempty"`
|
||||
SourceProjection uint64 `json:"sourceProjection,omitempty"`
|
||||
ProjectionVersion uint64 `json:"projectionVersion,omitempty"`
|
||||
CoveredCount int `json:"coveredCount,omitempty"`
|
||||
CoveredPrefixHash string `json:"coveredPrefixHash,omitempty"`
|
||||
InputHash string `json:"inputHash,omitempty"`
|
||||
OutputHash string `json:"outputHash,omitempty"`
|
||||
InputTokens int `json:"inputTokens,omitempty"`
|
||||
ResultTokens int `json:"resultTokens,omitempty"`
|
||||
SavedTokens int `json:"savedTokens,omitempty"`
|
||||
AffectedToolResults int `json:"affectedToolResults,omitempty"`
|
||||
SummaryHash string `json:"summaryHash,omitempty"`
|
||||
CacheBreak bool `json:"cacheBreak,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
BlockedInputHash string `json:"blockedInputHash,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
func contextMaintenanceInfo(snapshot agent.ContextMaintenanceSnapshot) *ContextMaintenanceInfo {
|
||||
if snapshot.CanonicalTokens == 0 && snapshot.ProjectedTokens == 0 && snapshot.LastReceipt == nil {
|
||||
return nil
|
||||
}
|
||||
info := &ContextMaintenanceInfo{
|
||||
CanonicalTokens: snapshot.CanonicalTokens, ProjectedTokens: snapshot.ProjectedTokens,
|
||||
SummaryTokens: snapshot.SummaryTokens, LastSavedTokens: snapshot.LastSavedTokens,
|
||||
// Snip/Force remain zero for one-version compatibility with older frontends.
|
||||
FoldTrigger: snapshot.TriggerTokens, TriggerTokens: snapshot.TriggerTokens,
|
||||
CheckpointState: snapshot.CheckpointState, HardInputCeiling: snapshot.HardInputCeiling,
|
||||
Headroom: snapshot.Headroom, ProjectionVersion: snapshot.ProjectionVersion,
|
||||
Blocked: snapshot.Blocked,
|
||||
}
|
||||
if source := snapshot.LastReceipt; source != nil {
|
||||
info.LastReceipt = contextMaintenanceReceiptInfo(source)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func contextMaintenanceReceiptInfo(source *agent.ContextMaintenanceReceipt) *ContextMaintenanceReceiptInfo {
|
||||
receipt := &ContextMaintenanceReceiptInfo{
|
||||
OperationID: source.OperationID, Status: source.Status, Action: source.Action, Trigger: source.Trigger,
|
||||
SourceProjection: source.SourceProjection, ProjectionVersion: source.ProjectionVersion,
|
||||
CoveredCount: source.CoveredCount, CoveredPrefixHash: source.CoveredPrefixHash,
|
||||
InputHash: source.InputHash, OutputHash: source.OutputHash,
|
||||
InputTokens: source.InputTokens, ResultTokens: source.ResultTokens, SavedTokens: source.SavedTokens,
|
||||
AffectedToolResults: source.AffectedToolResults, SummaryHash: source.SummaryHash,
|
||||
CacheBreak: source.CacheBreak, Reason: source.Reason, BlockedInputHash: source.BlockedInputHash,
|
||||
}
|
||||
if !source.CreatedAt.IsZero() {
|
||||
receipt.CreatedAt = source.CreatedAt.Format(time.RFC3339Nano)
|
||||
}
|
||||
return receipt
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestContextMaintenanceInfoUsesOptionalCamelCaseWailsFields(t *testing.T) {
|
||||
b, err := json.Marshal(ContextInfo{Maintenance: &ContextMaintenanceInfo{
|
||||
CanonicalTokens: 800_000, ProjectedTokens: 160_000,
|
||||
TriggerTokens: 850_000, CheckpointState: "applied",
|
||||
LastReceipt: &ContextMaintenanceReceiptInfo{
|
||||
OperationID: "op", Action: "summary", SavedTokens: 4096,
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := string(b)
|
||||
for _, want := range []string{
|
||||
`"maintenance"`, `"projectedTokens":160000`, `"canonicalTokens":800000`,
|
||||
`"triggerTokens":850000`, `"checkpointState":"applied"`,
|
||||
`"lastReceipt"`, `"operationId":"op"`, `"savedTokens":4096`, `"action":"summary"`,
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("ContextInfo JSON missing %s: %s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "operation_id") || strings.Contains(got, "saved_tokens") {
|
||||
t.Fatalf("ContextInfo leaked sidecar snake_case into Wails JSON: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// crash_app.go is the crash/feedback/performance reporting surface. Frontend
|
||||
// reports are sent on an explicit user click. Native fatal/lifecycle reports are
|
||||
// queued locally and sent on a later launch only when desktop telemetry is on.
|
||||
|
||||
var crashEndpoint = "https://crash.reasonix.io/v1/report"
|
||||
|
||||
const maxCrashDetailBytes = 16 << 10
|
||||
const maxCrashStackBytes = 8 << 10
|
||||
const maxCrashFieldBytes = 4 << 10
|
||||
const maxCrashBreadcrumbs = 30
|
||||
|
||||
var (
|
||||
userPathSegment = regexp.MustCompile(`(?i)([A-Z]:\\Users\\|/(?:home|Users)/)[^/\\:\s"']+`)
|
||||
emailPattern = regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`)
|
||||
secretKeyValuePattern = regexp.MustCompile(`(?i)\b(api[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|authorization|secret|password|passwd|pwd|token)\b\s*[:=]\s*(?:Bearer\s+)?['"]?[^'"\s,;]+['"]?`)
|
||||
bearerTokenPattern = regexp.MustCompile(`(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{16,}`)
|
||||
explicitKeyPattern = regexp.MustCompile(`\b(?:sk|rk)-(?:proj-)?[A-Za-z0-9_-]{16,}\b`)
|
||||
envIdentifierPattern = regexp.MustCompile(`\b[A-Z][A-Z0-9_]*(?:API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|SECRET|TOKEN|PASSWORD|PASSWD|PWD)[A-Z0-9_]*\b`)
|
||||
jwtPattern = regexp.MustCompile(`\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b`)
|
||||
longHexPattern = regexp.MustCompile(`\b[0-9a-fA-F]{32,}\b`)
|
||||
longBase64Pattern = regexp.MustCompile(`[A-Za-z0-9+/]{40,}={0,2}`)
|
||||
longBase64URLPattern = regexp.MustCompile(`\b[A-Za-z0-9_-]{48,}\b`)
|
||||
)
|
||||
|
||||
func scrubUserPaths(s string) string {
|
||||
return userPathSegment.ReplaceAllString(s, "${1}_")
|
||||
}
|
||||
|
||||
func scrubSensitiveText(s string) string {
|
||||
s = scrubUserPaths(s)
|
||||
s = emailPattern.ReplaceAllString(s, "[redacted-email]")
|
||||
s = bearerTokenPattern.ReplaceAllString(s, "Bearer [redacted]")
|
||||
s = secretKeyValuePattern.ReplaceAllString(s, "${1}=[redacted]")
|
||||
s = envIdentifierPattern.ReplaceAllString(s, "[redacted-env]")
|
||||
s = jwtPattern.ReplaceAllString(s, "[redacted-jwt]")
|
||||
s = explicitKeyPattern.ReplaceAllString(s, "[redacted-key]")
|
||||
s = longHexPattern.ReplaceAllString(s, "[redacted-hex]")
|
||||
s = longBase64Pattern.ReplaceAllString(s, "[redacted-token]")
|
||||
s = longBase64URLPattern.ReplaceAllString(s, "[redacted-token]")
|
||||
return s
|
||||
}
|
||||
|
||||
type crashBreadcrumb struct {
|
||||
T int64 `json:"t,omitempty"`
|
||||
Cat string `json:"cat,omitempty"`
|
||||
Msg string `json:"msg,omitempty"`
|
||||
}
|
||||
|
||||
type crashReport struct {
|
||||
InstallID string `json:"installId,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
Message string `json:"message"`
|
||||
Device deviceInfo `json:"device"`
|
||||
SchemaVersion int `json:"schemaVersion,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Label string `json:"label,omitempty"`
|
||||
ErrorType string `json:"errorType,omitempty"`
|
||||
ErrorMessage string `json:"errorMessage,omitempty"`
|
||||
Stack string `json:"stack,omitempty"`
|
||||
ComponentStack string `json:"componentStack,omitempty"`
|
||||
TopFrame string `json:"topFrame,omitempty"`
|
||||
FingerprintHint string `json:"fingerprintHint,omitempty"`
|
||||
BuildCommit string `json:"buildCommit,omitempty"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
View string `json:"view,omitempty"`
|
||||
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs,omitempty"`
|
||||
OccurredAt string `json:"occurredAt,omitempty"`
|
||||
WebRuntime *webRuntimeDiagnostic `json:"webRuntime,omitempty"`
|
||||
// WebView2 is retained only so pending reports written by preview builds can
|
||||
// still be decoded and forwarded after upgrade. New reports use WebRuntime.
|
||||
WebView2 *webView2Diagnostic `json:"webview2,omitempty"`
|
||||
}
|
||||
|
||||
type frontendCrashPayload struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Source string `json:"source"`
|
||||
Label string `json:"label"`
|
||||
Message string `json:"message"`
|
||||
ErrorType string `json:"errorType"`
|
||||
ErrorMessage string `json:"errorMessage"`
|
||||
Stack string `json:"stack"`
|
||||
ComponentStack string `json:"componentStack"`
|
||||
TopFrame string `json:"topFrame"`
|
||||
FingerprintHint string `json:"fingerprintHint"`
|
||||
BuildCommit string `json:"buildCommit"`
|
||||
Channel string `json:"channel"`
|
||||
Language string `json:"language"`
|
||||
View string `json:"view"`
|
||||
Breadcrumbs []crashBreadcrumb `json:"breadcrumbs"`
|
||||
OccurredAt string `json:"occurredAt"`
|
||||
}
|
||||
|
||||
func normalizeReportKind(kind string) (string, bool) {
|
||||
switch strings.TrimSpace(kind) {
|
||||
case "crash", "exception", "feedback", "performance", "bot":
|
||||
return strings.TrimSpace(kind), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func clipCrashField(s string, max int) string {
|
||||
if len(s) > max {
|
||||
return s[:max]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func sanitizeCrashField(s string, max int) string {
|
||||
return clipCrashField(scrubUserPaths(strings.TrimSpace(s)), max)
|
||||
}
|
||||
|
||||
func sanitizeCrashText(s string, max int) string {
|
||||
return clipCrashField(strings.TrimSpace(scrubSensitiveText(s)), max)
|
||||
}
|
||||
|
||||
func sanitizeBreadcrumbs(in []crashBreadcrumb) []crashBreadcrumb {
|
||||
if len(in) > maxCrashBreadcrumbs {
|
||||
in = in[len(in)-maxCrashBreadcrumbs:]
|
||||
}
|
||||
out := make([]crashBreadcrumb, 0, len(in))
|
||||
for _, b := range in {
|
||||
cat := sanitizeCrashField(b.Cat, 64)
|
||||
msg := sanitizeCrashText(b.Msg, 240)
|
||||
if cat == "" && msg == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, crashBreadcrumb{T: b.T, Cat: cat, Msg: msg})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func baseCrashReport(kind string) crashReport {
|
||||
return crashReport{
|
||||
Kind: kind,
|
||||
Version: version,
|
||||
OS: runtime.GOOS,
|
||||
Arch: runtime.GOARCH,
|
||||
Device: collectDeviceInfo(),
|
||||
Channel: channel,
|
||||
}
|
||||
}
|
||||
|
||||
func topFrameFromStack(stack string) string {
|
||||
for line := range strings.SplitSeq(stack, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(line, ".go:") || strings.Contains(line, ".ts:") || strings.Contains(line, ".tsx:") || strings.Contains(line, ".js:") || strings.Contains(line, ".jsx:") {
|
||||
if strings.Contains(line, "/runtime/") || strings.Contains(line, `\runtime\`) || strings.Contains(line, "crash_pending.go") {
|
||||
continue
|
||||
}
|
||||
return sanitizeCrashText(line, 300)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func nativeResourceContext() string {
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
mb := func(n uint64) string {
|
||||
return fmt.Sprintf("%.1f MB", float64(n)/1024/1024)
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"go heap alloc: " + mb(m.Alloc),
|
||||
"go heap sys: " + mb(m.HeapSys),
|
||||
"go total sys: " + mb(m.Sys),
|
||||
fmt.Sprintf("goroutines: %d", runtime.NumGoroutine()),
|
||||
fmt.Sprintf("gc cycles: %d", m.NumGC),
|
||||
}, "\n")
|
||||
}
|
||||
|
||||
func appendNativeResourceContext(kind, message string) string {
|
||||
if kind != "performance" {
|
||||
return message
|
||||
}
|
||||
return sanitizeCrashText(message+"\n\n--- native runtime context ---\n"+nativeResourceContext(), maxCrashDetailBytes)
|
||||
}
|
||||
|
||||
func crashReportFromDetail(kind, detail string) (crashReport, error) {
|
||||
rawKind := kind
|
||||
kind, ok := normalizeReportKind(kind)
|
||||
if !ok {
|
||||
return crashReport{}, fmt.Errorf("unknown report kind %q", rawKind)
|
||||
}
|
||||
if strings.TrimSpace(detail) == "" {
|
||||
return crashReport{}, fmt.Errorf("empty report")
|
||||
}
|
||||
r := baseCrashReport(kind)
|
||||
|
||||
var payload frontendCrashPayload
|
||||
if json.Unmarshal([]byte(detail), &payload) == nil && payload.SchemaVersion == 2 {
|
||||
if payloadKind, ok := normalizeReportKind(payload.Kind); ok {
|
||||
r.Kind = payloadKind
|
||||
}
|
||||
r.SchemaVersion = payload.SchemaVersion
|
||||
r.Source = sanitizeCrashField(payload.Source, 32)
|
||||
r.Label = sanitizeCrashField(payload.Label, 64)
|
||||
r.ErrorType = sanitizeCrashField(payload.ErrorType, 128)
|
||||
r.ErrorMessage = sanitizeCrashText(payload.ErrorMessage, maxCrashFieldBytes)
|
||||
r.Stack = sanitizeCrashText(payload.Stack, maxCrashStackBytes)
|
||||
r.ComponentStack = sanitizeCrashText(payload.ComponentStack, maxCrashStackBytes)
|
||||
r.TopFrame = sanitizeCrashText(payload.TopFrame, 300)
|
||||
r.FingerprintHint = sanitizeCrashText(payload.FingerprintHint, 300)
|
||||
r.BuildCommit = sanitizeCrashField(payload.BuildCommit, 64)
|
||||
r.Channel = sanitizeCrashField(payload.Channel, 32)
|
||||
r.Language = sanitizeCrashField(payload.Language, 64)
|
||||
r.View = sanitizeCrashText(payload.View, 200)
|
||||
r.Breadcrumbs = sanitizeBreadcrumbs(payload.Breadcrumbs)
|
||||
r.OccurredAt = sanitizeCrashField(payload.OccurredAt, 64)
|
||||
r.Message = sanitizeCrashText(payload.Message, maxCrashDetailBytes)
|
||||
if r.TopFrame == "" {
|
||||
r.TopFrame = topFrameFromStack(r.Stack)
|
||||
}
|
||||
if r.Message == "" {
|
||||
r.Message = sanitizeCrashText(fmt.Sprintf("[%s]\n\n%s", r.Label, r.ErrorMessage), maxCrashDetailBytes)
|
||||
}
|
||||
if r.Source == "" {
|
||||
r.Source = "frontend"
|
||||
}
|
||||
r.Message = appendNativeResourceContext(r.Kind, r.Message)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
r.SchemaVersion = 1
|
||||
r.Source = "legacy"
|
||||
r.Label = kind
|
||||
r.Message = sanitizeCrashText(detail, maxCrashDetailBytes)
|
||||
r.Message = appendNativeResourceContext(r.Kind, r.Message)
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (a *App) ReportCrash(kind, detail string) error {
|
||||
r, err := crashReportFromDetail(kind, detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c, err := httpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return postCrashReport(a.reqCtx(), c, crashEndpoint, r)
|
||||
}
|
||||
|
||||
func postCrashReport(ctx context.Context, c *http.Client, endpoint string, r crashReport) error {
|
||||
// Pending crash files deliberately omit the anonymous installation id. Add
|
||||
// it only at send time, under the same desktop.telemetry opt-in as pings.
|
||||
if cfg, err := config.Load(); err == nil && cfg.DesktopTelemetry() {
|
||||
if id, idErr := installID(); idErr == nil {
|
||||
r.InstallID = id
|
||||
}
|
||||
}
|
||||
body, err := json.Marshal(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("crash endpoint returned %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScrubUserPaths(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`at C:\Users\yuhua\proj\app.ts:12:3`: `at C:\Users\_\proj\app.ts:12:3`,
|
||||
`at c:\users\someone\x.go`: `at c:\users\_\x.go`,
|
||||
`/home/bob/.reasonix/config.toml`: `/home/_/.reasonix/config.toml`,
|
||||
`/Users/alice/Library/Logs`: `/Users/_/Library/Logs`,
|
||||
`Error: ENOENT open '/home/bob/secret'`: `Error: ENOENT open '/home/_/secret'`,
|
||||
`no user path here: /usr/lib/node`: `no user path here: /usr/lib/node`,
|
||||
"first /home/a/x\nsecond C:\\Users\\b\\y": "first /home/_/x\nsecond C:\\Users\\_\\y",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := scrubUserPaths(in); got != want {
|
||||
t.Errorf("scrubUserPaths(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestScrubSensitiveText(t *testing.T) {
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
|
||||
longHex := "0123456789abcdef0123456789abcdef"
|
||||
jwt := "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.signature"
|
||||
got := scrubSensitiveText("user [email protected] Authorization: Bearer " + bearer + " api_key=" + apiKey + " jwt " + jwt + " hash " + longHex + " env FEISHU_BOT_APP_SECRET WEIXIN_BOT_TOKEN short abc1234 path /Users/alice/x")
|
||||
|
||||
for _, leaked := range []string{"[email protected]", bearer, apiKey, jwt, longHex, "FEISHU_BOT_APP_SECRET", "WEIXIN_BOT_TOKEN", "alice"} {
|
||||
if strings.Contains(got, leaked) {
|
||||
t.Fatalf("sensitive text leaked %q in %q", leaked, got)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"[redacted-email]", "Authorization=[redacted]", "api_key=[redacted]", "[redacted-jwt]", "[redacted-hex]", "[redacted-env]", "short abc1234", "/Users/_/x"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("scrubSensitiveText() = %q, want it to contain %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostCrashReport(t *testing.T) {
|
||||
var got crashReport
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("method = %s, want POST", r.Method)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); ct != "application/json" {
|
||||
t.Errorf("content-type = %q", ct)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
if err := json.Unmarshal(body, &got); err != nil {
|
||||
t.Errorf("body not JSON: %v", err)
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
r := crashReport{Kind: "crash", Version: "v9.9.9", OS: "windows", Arch: "amd64", Message: "[react]\nboom"}
|
||||
if err := postCrashReport(context.Background(), srv.Client(), srv.URL, r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !installIDPattern.MatchString(got.InstallID) {
|
||||
t.Fatalf("send-time install id = %q", got.InstallID)
|
||||
}
|
||||
got.InstallID = ""
|
||||
if !reflect.DeepEqual(got, r) {
|
||||
t.Errorf("server received %+v, want %+v", got, r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostCrashReportRejectedStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
err := postCrashReport(context.Background(), srv.Client(), srv.URL, crashReport{Kind: "crash"})
|
||||
if err == nil || !strings.Contains(err.Error(), "429") {
|
||||
t.Fatalf("want 429 error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportCrashRejectsBadInput(t *testing.T) {
|
||||
app := NewApp()
|
||||
if err := app.ReportCrash("telemetry", "x"); err == nil {
|
||||
t.Error("unknown kind should be rejected")
|
||||
}
|
||||
if err := app.ReportCrash("crash", ""); err == nil {
|
||||
t.Error("empty detail should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrashReportFromStructuredDetail(t *testing.T) {
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
secretHex := "abcdefabcdefabcdefabcdefabcdef12"
|
||||
buildCommit := "0123456789abcdef0123456789abcdef01234567"
|
||||
payload := frontendCrashPayload{
|
||||
SchemaVersion: 2,
|
||||
Kind: "exception",
|
||||
Source: "frontend",
|
||||
Label: "unhandledrejection",
|
||||
Message: "[unhandledrejection]\n\ninvalid argument at C:\\Users\\alice\\app.ts:1 from [email protected]",
|
||||
ErrorType: "TypeError",
|
||||
ErrorMessage: "invalid argument at /Users/alice/project/app.ts api_key=" + apiKey,
|
||||
Stack: "TypeError: invalid argument\n at run (/Users/alice/project/app.ts:12:3)\nsecret=" + secretHex,
|
||||
TopFrame: "at run (/Users/alice/project/app.ts:12:3)",
|
||||
FingerprintHint: "build:01234567|view:app://reasonix/|cats:startup>tabs",
|
||||
BuildCommit: buildCommit,
|
||||
Channel: "canary",
|
||||
Language: "zh-CN",
|
||||
View: "wails://wails.localhost/index.html?token=" + secretHex,
|
||||
Breadcrumbs: []crashBreadcrumb{{T: 1, Cat: "bridge", Msg: "turn SubmitToTab token=" + apiKey}},
|
||||
}
|
||||
detail, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := crashReportFromDetail("crash", string(detail))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Kind != "exception" || r.Source != "frontend" || r.Label != "unhandledrejection" {
|
||||
t.Fatalf("structured fields not preserved: %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Message, "alice") || strings.Contains(r.ErrorMessage, "alice") || strings.Contains(r.Stack, "alice") {
|
||||
t.Fatalf("user path was not scrubbed: %+v", r)
|
||||
}
|
||||
if r.TopFrame == "" || r.FingerprintHint != payload.FingerprintHint || r.BuildCommit != buildCommit || r.Channel != "canary" || len(r.Breadcrumbs) != 1 {
|
||||
t.Fatalf("metadata missing: %+v", r)
|
||||
}
|
||||
freeText := strings.Join([]string{
|
||||
r.Message,
|
||||
r.ErrorMessage,
|
||||
r.Stack,
|
||||
r.ComponentStack,
|
||||
r.TopFrame,
|
||||
r.View,
|
||||
r.Breadcrumbs[0].Msg,
|
||||
}, "\n")
|
||||
for _, leaked := range []string{apiKey, secretHex, "[email protected]"} {
|
||||
if strings.Contains(freeText, leaked) {
|
||||
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrashReportFromPerformanceDetail(t *testing.T) {
|
||||
payload := frontendCrashPayload{
|
||||
SchemaVersion: 2,
|
||||
Kind: "performance",
|
||||
Source: "frontend.performance",
|
||||
Label: "performance.pressure",
|
||||
Message: "[performance.pressure]\n\n--- performance context ---\nreason: event loop lag 1300ms",
|
||||
ErrorType: "PerformancePressure",
|
||||
ErrorMessage: "UI responsiveness degraded because the app observed long tasks, event-loop lag, or high JS heap pressure.",
|
||||
TopFrame: "frontend.performance",
|
||||
}
|
||||
detail, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := crashReportFromDetail("performance", string(detail))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Kind != "performance" || r.Source != "frontend.performance" || r.Label != "performance.pressure" {
|
||||
t.Fatalf("performance fields not preserved: %+v", r)
|
||||
}
|
||||
if !strings.Contains(r.Message, "--- native runtime context ---") || !strings.Contains(r.Message, "goroutines:") {
|
||||
t.Fatalf("native runtime context missing from performance report: %q", r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrashReportFromBotDetail(t *testing.T) {
|
||||
token := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
|
||||
payload := frontendCrashPayload{
|
||||
SchemaVersion: 2,
|
||||
Kind: "bot",
|
||||
Source: "bot.runtime",
|
||||
Label: "bot.feishu.lark.send",
|
||||
Message: "[bot]\n\nfailed at /Users/alice/project with token=" + token,
|
||||
ErrorType: "BotConnectionDiagnostic",
|
||||
ErrorMessage: "send failed with Bearer " + token,
|
||||
TopFrame: "bot.send",
|
||||
}
|
||||
detail, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r, err := crashReportFromDetail("bot", string(detail))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Kind != "bot" || r.Source != "bot.runtime" || r.Label != "bot.feishu.lark.send" {
|
||||
t.Fatalf("bot fields not preserved: %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Message, "alice") || strings.Contains(r.Message, token) || strings.Contains(r.ErrorMessage, token) {
|
||||
t.Fatalf("bot report was not scrubbed: %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Message, "--- native runtime context ---") {
|
||||
t.Fatalf("bot report should not include performance runtime context: %q", r.Message)
|
||||
}
|
||||
}
|
||||
@@ -1,404 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/repair"
|
||||
)
|
||||
|
||||
func TestParseWebView2ProcessFailure(t *testing.T) {
|
||||
kind, ok := parseWebView2ProcessFailure("windows | WebVie2wProcess failed with kind 6")
|
||||
if !ok || kind != 6 {
|
||||
t.Fatalf("kind=%d ok=%v", kind, ok)
|
||||
}
|
||||
if _, ok := parseWebView2ProcessFailure("unrelated failure"); ok {
|
||||
t.Fatal("unrelated log message matched WebView2 failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2ProcessFailureReportIsStructured(t *testing.T) {
|
||||
report := webView2ProcessFailureReportWithContext(2, 3, "132.0.2957.140")
|
||||
if report.Source != "web.runtime.fallback" || report.Label != "windows.webview2.process_failed" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if report.FingerprintHint != "web.runtime.webview2.render_process_unresponsive.unknown.unknown" {
|
||||
t.Fatalf("fingerprint hint = %q", report.FingerprintHint)
|
||||
}
|
||||
for _, want := range []string{"runtime version: 132.0.2957.140", "same-process occurrence: 3", "exit code: unavailable"} {
|
||||
if !strings.Contains(report.Message, want) {
|
||||
t.Fatalf("report message missing %q: %s", want, report.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2FailureTrackerDeduplicatesSessionBursts(t *testing.T) {
|
||||
var tracker webView2FailureTracker
|
||||
base := time.Date(2026, 8, 3, 10, 0, 0, 0, time.UTC)
|
||||
if occurrence, report := tracker.observe(2, base); occurrence != 1 || !report {
|
||||
t.Fatalf("first observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(2, base.Add(time.Minute)); occurrence != 2 || report {
|
||||
t.Fatalf("burst observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(2, base.Add(webView2FailureReportCooldown)); occurrence != 3 || !report {
|
||||
t.Fatalf("post-cooldown observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
if occurrence, report := tracker.observe(6, base.Add(time.Minute)); occurrence != 1 || !report {
|
||||
t.Fatalf("different kind observation = (%d, %v)", occurrence, report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2NativeFailureClassification(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
event webView2NativeEvent
|
||||
kind string
|
||||
outcome string
|
||||
recovery string
|
||||
}{
|
||||
{name: "browser fatal", event: webView2NativeEvent{Kind: 0, ReasonAvailable: true, Reason: 3}, kind: "crash", outcome: "fatal_app_exit", recovery: webView2RecoveryNotApplicable},
|
||||
{name: "renderer recovered", event: webView2NativeEvent{Kind: 1, Recovery: webView2RecoverySucceeded}, kind: "performance", outcome: "recovered", recovery: webView2RecoverySucceeded},
|
||||
{name: "renderer recovery failed", event: webView2NativeEvent{Kind: 2, Recovery: webView2RecoveryFailed}, kind: "exception", outcome: "recovery_failed", recovery: webView2RecoveryFailed},
|
||||
{name: "gpu degraded", event: webView2NativeEvent{Kind: 6}, kind: "performance", outcome: "degraded", recovery: webView2RecoveryNotApplicable},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
report, outcome := webView2NativeFailureReport(tt.event, "132.0.1", true)
|
||||
if report.Kind != tt.kind || outcome != tt.outcome || report.WebRuntime == nil || report.WebRuntime.Recovery != tt.recovery {
|
||||
t.Fatalf("report=%+v diagnostic=%+v outcome=%q", report, report.WebRuntime, outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebView2NativeFailureSanitizesModuleAndFingerprint(t *testing.T) {
|
||||
report, _ := webView2NativeFailureReport(webView2NativeEvent{
|
||||
Kind: 2,
|
||||
Reason: 1,
|
||||
ReasonAvailable: true,
|
||||
ExitCode: 259,
|
||||
ExitCodeAvailable: true,
|
||||
FailureSourceModule: `C:\Users\alice\Security Suite\inject.dll`,
|
||||
Recovery: webView2RecoveryFailed,
|
||||
}, "", false)
|
||||
if got := report.WebRuntime.FailureSourceModule; got != "inject.dll" {
|
||||
t.Fatalf("failure source module = %q", got)
|
||||
}
|
||||
if strings.Contains(report.Message, `C:\Users`) || strings.Contains(report.FingerprintHint, "259") {
|
||||
t.Fatalf("report leaked a path or fingerprinted STILL_ACTIVE: %+v", report)
|
||||
}
|
||||
if report.FingerprintHint != "web.runtime.webview2.render_process_unresponsive.unresponsive.unknown" {
|
||||
t.Fatalf("fingerprint = %q", report.FingerprintHint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebKitNativeFailureClassification(t *testing.T) {
|
||||
context := webRuntimeContext{Engine: "webkitgtk", RuntimeVersion: "2.42.5", GPUMode: "on_demand"}
|
||||
recovered, outcome, failure := webKitNativeFailureReport(webKitNativeEvent{reason: 0, recovery: webKitRecoverySucceeded, runtimeContext: context})
|
||||
if recovered.Kind != "performance" || outcome != "recovered" || failure != "webkitgtk.web_process.crashed" {
|
||||
t.Fatalf("recovered report=%+v outcome=%q failure=%q", recovered, outcome, failure)
|
||||
}
|
||||
if recovered.WebRuntime == nil || recovered.WebRuntime.Engine != "webkitgtk" || recovered.WebRuntime.GPUMode != "on_demand" {
|
||||
t.Fatalf("runtime diagnostic=%+v", recovered.WebRuntime)
|
||||
}
|
||||
failed, outcome, _ := webKitNativeFailureReport(webKitNativeEvent{reason: 1, recovery: webKitRecoveryFailed, runtimeContext: context})
|
||||
if failed.Kind != "exception" || outcome != "recovery_failed" || failed.WebRuntime.Reason != "out_of_memory" {
|
||||
t.Fatalf("failed report=%+v outcome=%q", failed, outcome)
|
||||
}
|
||||
degraded, outcome, _ := webKitNativeFailureReport(webKitNativeEvent{reason: 99, recovery: webKitRecoveryNotApplicable, runtimeContext: context})
|
||||
if degraded.Kind != "performance" || outcome != "degraded" || degraded.WebRuntime.Reason != "unknown" {
|
||||
t.Fatalf("degraded report=%+v outcome=%q", degraded, outcome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebKitObserverReloadIsAbnormalPathOnly(t *testing.T) {
|
||||
source, err := os.ReadFile("webkit_diagnostics_linux.c")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := string(source)
|
||||
if strings.Count(text, "webkit_web_view_reload(") != 1 {
|
||||
t.Fatalf("observer must have exactly one native reload call site")
|
||||
}
|
||||
_, recoveryTail, ok := strings.Cut(text, "static gboolean reasonix_reload_after_termination")
|
||||
if !ok {
|
||||
t.Fatal("native reload is not deferred until after termination signal dispatch")
|
||||
}
|
||||
reloadHelper, terminationTail, ok := strings.Cut(recoveryTail, "static void reasonix_web_process_terminated")
|
||||
if !ok || !strings.Contains(reloadHelper, "webkit_web_view_reload(") {
|
||||
t.Fatal("native reload escaped the deferred recovery helper")
|
||||
}
|
||||
terminationBody, _, ok := strings.Cut(terminationTail, "static gboolean reasonix_load_failed")
|
||||
if !ok || !strings.Contains(terminationBody, "reasonix_reload_after_termination") {
|
||||
t.Fatal("web-process termination does not schedule the bounded recovery helper")
|
||||
}
|
||||
if !strings.Contains(text, "if (!reasonix_recovery_pending) return;") ||
|
||||
!strings.Contains(text, "if (reasonix_recovery_load_started && event == WEBKIT_LOAD_FINISHED)") {
|
||||
t.Fatal("ordinary load-finished events are not gated by recovery state")
|
||||
}
|
||||
if !strings.Contains(text, "reasonix_recovery_pending && reasonix_recovery_load_started") {
|
||||
t.Fatal("a stale load failure could be attributed to the recovery navigation")
|
||||
}
|
||||
for _, forbidden := range []string{"fopen(", "open(", "curl_", "send(", "recv("} {
|
||||
if strings.Contains(text, forbidden) {
|
||||
t.Fatalf("GTK callback source contains blocking I/O primitive %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviousRunReportUsesOnlyBoundedLifecycleContext(t *testing.T) {
|
||||
report := previousRunReport(repair.PreviousRunObservation{
|
||||
Abnormal: true,
|
||||
Phase: "healthy",
|
||||
Version: "v2",
|
||||
InstallProfile: "installer",
|
||||
UpdateFrom: "v1",
|
||||
UpdateTo: "v2",
|
||||
UptimeBucket: "m_2_10",
|
||||
})
|
||||
if report.Source != "native.lifecycle.legacy" || report.Label != "desktop.legacy_abnormal_exit" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if !strings.Contains(report.Message, "uptime bucket: m_2_10") {
|
||||
t.Fatalf("message missing bounded uptime: %q", report.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopLifecycleReportUsesCurrentLifecycleNamespace(t *testing.T) {
|
||||
report := desktopLifecycleReport(desktopLifecycleObservation{
|
||||
Version: "v1.23.0", Channel: "stable", Phase: "healthy",
|
||||
StartedAt: "2026-08-10T01:00:00Z", UpdatedAt: "2026-08-10T02:00:00Z",
|
||||
})
|
||||
if report.Source != "native.lifecycle" || report.Label != "desktop.abnormal_exit.v2" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
if report.FingerprintHint != "desktop.abnormal_exit.v2."+runtime.GOOS+".healthy" {
|
||||
t.Fatalf("fingerprint = %q", report.FingerprintHint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashQueuesAndRemovesRawDump(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const crashedPID = 424242
|
||||
raw := "fatal error: concurrent map writes\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
|
||||
path := fatalCrashPathForPID(crashedPID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
capturePreviousFatalCrash()
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("raw fatal dump was not removed: %v", err)
|
||||
}
|
||||
report, ok := readPending(t)
|
||||
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
|
||||
t.Fatalf("queued report = %+v ok=%v", report, ok)
|
||||
}
|
||||
if strings.Contains(report.Stack, "/home/alice") {
|
||||
t.Fatalf("fatal stack leaked home path: %q", report.Stack)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeFatalRuntimeDumpRemovesPanicValue(t *testing.T) {
|
||||
got := sanitizeFatalRuntimeDump("panic: private prompt text\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n")
|
||||
if strings.Contains(got, "private prompt text") || strings.Contains(got, "/home/alice") {
|
||||
t.Fatalf("fatal dump leaked user-controlled text: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "panic: [redacted panic value]") || !strings.Contains(got, "main.go:12") {
|
||||
t.Fatalf("fatal dump lost diagnostic structure: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashSkipsRuntimeDuplicateOfStructuredPanic(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const crashedPID = 424243
|
||||
path := fatalCrashPathForPID(crashedPID)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("panic: duplicate\n\ngoroutine 1 [running]:\nmain.run()\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
markFatalCrashCoveredForPID(crashedPID)
|
||||
capturePreviousFatalCrash()
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("runtime duplicate was queued despite structured panic marker")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashDoesNotTouchLiveOwner(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
const livePID = 424244
|
||||
path := fatalCrashPathForPID(livePID)
|
||||
raw := []byte("fatal error: still being written\n\ngoroutine 1 [running]:\n")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, raw, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldProcessAlive := fatalCrashProcessAlive
|
||||
fatalCrashProcessAlive = func(pid int) bool { return pid == livePID }
|
||||
t.Cleanup(func() { fatalCrashProcessAlive = oldProcessAlive })
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
got, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("live owner's fatal file was removed: %v", err)
|
||||
}
|
||||
if string(got) != string(raw) {
|
||||
t.Fatalf("live owner's fatal file changed: got %q want %q", got, raw)
|
||||
}
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("live owner's partial fatal output was queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashKeepsEmptyLegacyFile(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyFatalCrashPath(), nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
if _, err := os.Stat(legacyFatalCrashPath()); err != nil {
|
||||
t.Fatalf("empty legacy fatal file may belong to a live older process: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCapturePreviousFatalCrashMigratesLegacyDump(t *testing.T) {
|
||||
resetFatalCrashArtifacts(t)
|
||||
raw := "fatal error: legacy crash\n\ngoroutine 1 [running]:\nmain.run()\n\t/home/alice/project/main.go:12\n"
|
||||
if err := os.MkdirAll(filepath.Dir(legacyFatalCrashPath()), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(legacyFatalCrashPath(), []byte(raw), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
capturePreviousFatalCrash()
|
||||
|
||||
if _, err := os.Stat(legacyFatalCrashPath()); !os.IsNotExist(err) {
|
||||
t.Fatalf("captured legacy fatal dump was not removed: %v", err)
|
||||
}
|
||||
report, ok := readPending(t)
|
||||
if !ok || report.Label != "go.fatal" || report.Source != "go.runtime" {
|
||||
t.Fatalf("legacy fatal dump was not migrated: report=%+v ok=%v", report, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitWindowRestoreRequiresNativeConfirmation(t *testing.T) {
|
||||
ticks := make(chan time.Time)
|
||||
deadline := make(chan time.Time, 1)
|
||||
deadline <- time.Now()
|
||||
|
||||
if awaitWindowRestoreConfirmation(func() bool { return false }, ticks, deadline) {
|
||||
t.Fatal("an enqueued show request without native confirmation was treated as restored")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAwaitWindowRestoreAcceptsConfirmationAfterTick(t *testing.T) {
|
||||
ticks := make(chan time.Time, 1)
|
||||
deadline := make(chan time.Time)
|
||||
var confirmed atomic.Bool
|
||||
result := make(chan bool, 1)
|
||||
go func() {
|
||||
result <- awaitWindowRestoreConfirmation(confirmed.Load, ticks, deadline)
|
||||
}()
|
||||
|
||||
confirmed.Store(true)
|
||||
ticks <- time.Now()
|
||||
if !<-result {
|
||||
t.Fatal("native window confirmation was not accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSupersededWindowRestoreDoesNotRemoveLatestJournal(t *testing.T) {
|
||||
path := windowRestoreStatePath()
|
||||
_ = os.Remove(path)
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
oldSequence := windowRestoreSequence.Load()
|
||||
t.Cleanup(func() { windowRestoreSequence.Store(oldSequence) })
|
||||
|
||||
latest := windowRestoreState{
|
||||
SchemaVersion: windowRestoreStateVersion,
|
||||
PID: os.Getpid(),
|
||||
AttemptID: 2,
|
||||
Source: "tray",
|
||||
StartedAt: "2026-07-24T08:01:00Z",
|
||||
}
|
||||
if !writeWindowRestoreState(latest) {
|
||||
t.Fatal("write latest window restore state")
|
||||
}
|
||||
windowRestoreSequence.Store(latest.AttemptID)
|
||||
|
||||
NewApp().completeWindowRestoreAttempt(1, windowRestoreState{AttemptID: 1}, true)
|
||||
|
||||
got, err := readWindowRestoreState()
|
||||
if err != nil {
|
||||
t.Fatalf("latest journal was removed by superseded attempt: %v", err)
|
||||
}
|
||||
if got != latest {
|
||||
t.Fatalf("latest journal changed: got %+v want %+v", got, latest)
|
||||
}
|
||||
}
|
||||
|
||||
func resetFatalCrashArtifacts(t *testing.T) {
|
||||
t.Helper()
|
||||
oldProcessAlive := fatalCrashProcessAlive
|
||||
fatalCrashProcessAlive = func(int) bool { return false }
|
||||
removeAllPendingCrashes()
|
||||
_ = os.Remove(legacyFatalCrashPath())
|
||||
_ = os.Remove(legacyFatalCrashCoveredPath())
|
||||
_ = os.RemoveAll(fatalCrashDir())
|
||||
t.Cleanup(func() {
|
||||
removeAllPendingCrashes()
|
||||
_ = os.Remove(legacyFatalCrashPath())
|
||||
_ = os.Remove(legacyFatalCrashCoveredPath())
|
||||
_ = os.RemoveAll(fatalCrashDir())
|
||||
fatalCrashProcessAlive = oldProcessAlive
|
||||
})
|
||||
}
|
||||
|
||||
func TestWindowRestoreFailureReportSeparatesTimeoutAndSource(t *testing.T) {
|
||||
report := windowRestoreFailureReport("timeout", "second_instance", "2026-07-24T08:00:00Z")
|
||||
if report.Label != "windows.window_restore.timeout" || report.TopFrame != "windows.window_restore.second_instance" {
|
||||
t.Fatalf("report = %+v", report)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteWindowRestoreStateCreatesReadableJournal(t *testing.T) {
|
||||
path := windowRestoreStatePath()
|
||||
_ = os.Remove(path)
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
|
||||
want := windowRestoreState{
|
||||
SchemaVersion: windowRestoreStateVersion,
|
||||
PID: os.Getpid(),
|
||||
Source: "tray",
|
||||
StartedAt: "2026-07-24T08:00:00Z",
|
||||
}
|
||||
if !writeWindowRestoreState(want) {
|
||||
t.Fatal("writeWindowRestoreState returned false")
|
||||
}
|
||||
got, err := readWindowRestoreState()
|
||||
if err != nil {
|
||||
t.Fatalf("readWindowRestoreState: %v", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("journal = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
fatalCrashDirName = "crash-fatal"
|
||||
fatalCrashLogSuffix = ".log"
|
||||
fatalCrashCoveredSuffix = ".covered"
|
||||
legacyFatalCrashFile = "crash-fatal.log"
|
||||
legacyFatalCrashCoveredFile = "crash-fatal-covered"
|
||||
)
|
||||
|
||||
var fatalCrashProcessAlive = desktopProcessAlive
|
||||
|
||||
func fatalCrashDir() string {
|
||||
return filepath.Join(config.MemoryUserDir(), fatalCrashDirName)
|
||||
}
|
||||
|
||||
func fatalCrashPath() string {
|
||||
return fatalCrashPathForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPath() string {
|
||||
return fatalCrashCoveredPathForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func fatalCrashPathForPID(pid int) string {
|
||||
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashLogSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPathForPID(pid int) string {
|
||||
return filepath.Join(fatalCrashDir(), strconv.Itoa(pid)+fatalCrashCoveredSuffix)
|
||||
}
|
||||
|
||||
func legacyFatalCrashPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashFile)
|
||||
}
|
||||
|
||||
func legacyFatalCrashCoveredPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), legacyFatalCrashCoveredFile)
|
||||
}
|
||||
|
||||
func markFatalCrashCovered() {
|
||||
markFatalCrashCoveredForPID(os.Getpid())
|
||||
}
|
||||
|
||||
func markFatalCrashCoveredForPID(pid int) {
|
||||
path := fatalCrashCoveredPathForPID(pid)
|
||||
if os.MkdirAll(filepath.Dir(path), 0o700) == nil {
|
||||
_ = os.WriteFile(path, []byte("structured\n"), 0o600)
|
||||
}
|
||||
}
|
||||
|
||||
// capturePreviousFatalCrash converts runtime.SetCrashOutput dumps from dead
|
||||
// processes into the normal scrubbed queue. Per-PID files keep a routine second
|
||||
// launch from truncating or unlinking the running primary process's dump.
|
||||
func capturePreviousFatalCrash() {
|
||||
// Preserve compatibility with the single-file format used by older builds.
|
||||
// An empty legacy file may still be owned by a running older process, so it
|
||||
// must be left untouched until it contains a completed crash dump.
|
||||
captureFatalCrashFile(legacyFatalCrashPath(), legacyFatalCrashCoveredPath(), false)
|
||||
|
||||
entries, err := os.ReadDir(fatalCrashDir())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, ok := fatalCrashPID(entry.Name())
|
||||
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
|
||||
continue
|
||||
}
|
||||
captureFatalCrashFile(
|
||||
filepath.Join(fatalCrashDir(), entry.Name()),
|
||||
fatalCrashCoveredPathForPID(pid),
|
||||
true,
|
||||
)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
pid, ok := fatalCrashCoveredPID(entry.Name())
|
||||
if !ok || pid == os.Getpid() || fatalCrashProcessAlive(pid) {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(fatalCrashPathForPID(pid)); os.IsNotExist(err) {
|
||||
_ = os.Remove(filepath.Join(fatalCrashDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
// Best effort: succeeds only when no live/current process artifacts remain.
|
||||
_ = os.Remove(fatalCrashDir())
|
||||
}
|
||||
|
||||
func fatalCrashPID(name string) (int, bool) {
|
||||
return fatalCrashPIDWithSuffix(name, fatalCrashLogSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashCoveredPID(name string) (int, bool) {
|
||||
return fatalCrashPIDWithSuffix(name, fatalCrashCoveredSuffix)
|
||||
}
|
||||
|
||||
func fatalCrashPIDWithSuffix(name, suffix string) (int, bool) {
|
||||
if !strings.HasSuffix(name, suffix) {
|
||||
return 0, false
|
||||
}
|
||||
pid, err := strconv.Atoi(strings.TrimSuffix(name, suffix))
|
||||
return pid, err == nil && pid > 0
|
||||
}
|
||||
|
||||
func captureFatalCrashFile(path, coveredPath string, removeEmpty bool) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
occurredAt := time.Now().UTC()
|
||||
if info, statErr := f.Stat(); statErr == nil {
|
||||
occurredAt = info.ModTime().UTC()
|
||||
}
|
||||
raw, readErr := io.ReadAll(io.LimitReader(f, maxCrashStackBytes+1))
|
||||
_ = f.Close()
|
||||
if readErr != nil || len(strings.TrimSpace(string(raw))) == 0 {
|
||||
if removeEmpty {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(coveredPath); err == nil {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
stack := sanitizeFatalRuntimeDump(string(raw))
|
||||
report := baseCrashReport("crash")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "go.runtime"
|
||||
report.Label = "go.fatal"
|
||||
report.ErrorType = "GoRuntimeFatal"
|
||||
report.ErrorMessage = "Go runtime terminated the desktop process."
|
||||
report.Stack = stack
|
||||
report.TopFrame = topFrameFromStack(stack)
|
||||
report.FingerprintHint = "go.runtime.fatal"
|
||||
report.OccurredAt = occurredAt.Format(time.RFC3339)
|
||||
report.Message = sanitizeCrashText("[go.runtime.fatal]\n\n"+stack, maxCrashDetailBytes)
|
||||
if writePendingReport(report, true) {
|
||||
_ = os.Remove(coveredPath)
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeFatalRuntimeDump removes panic values and preamble text that could
|
||||
// originate in user-controlled errors, while retaining runtime classification
|
||||
// and symbolized goroutine stacks for diagnosis.
|
||||
func sanitizeFatalRuntimeDump(raw string) string {
|
||||
lines := strings.Split(raw, "\n")
|
||||
classification := "runtime crash output"
|
||||
stackStart := -1
|
||||
for i, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "fatal error:"):
|
||||
classification = sanitizeCrashText(trimmed, 256)
|
||||
case strings.HasPrefix(trimmed, "panic:"):
|
||||
classification = "panic: [redacted panic value]"
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "goroutine ") {
|
||||
stackStart = i
|
||||
break
|
||||
}
|
||||
}
|
||||
stack := ""
|
||||
if stackStart >= 0 {
|
||||
stack = strings.Join(lines[stackStart:], "\n")
|
||||
}
|
||||
return sanitizeCrashText(classification+"\n\n"+stack, maxCrashStackBytes)
|
||||
}
|
||||
|
||||
// installFatalCrashOutput asks the Go runtime to mirror unrecovered panics and
|
||||
// fatal runtime errors to a durable file. The runtime duplicates the descriptor,
|
||||
// so the file may be closed after SetCrashOutput returns.
|
||||
func installFatalCrashOutput() {
|
||||
path := fatalCrashPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := debug.SetCrashOutput(f, debug.CrashOptions{}); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(path)
|
||||
return
|
||||
}
|
||||
_ = f.Close()
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// crash_pending.go captures Go-side panics to disk and ships them on the next
|
||||
// launch. Frontend crashes are click-to-send, but an unrecovered Go panic kills the
|
||||
// process before the user can react, so the whole agent/provider/tool layer would
|
||||
// otherwise never surface a single report. The resend is gated on the same
|
||||
// desktop.telemetry opt-out as the launch ping.
|
||||
|
||||
const (
|
||||
pendingCrashFile = "crash-pending.json" // legacy single-report path
|
||||
pendingCrashQueueDir = "crash-pending"
|
||||
maxPendingCrashes = 10
|
||||
)
|
||||
|
||||
var (
|
||||
pendingCrashMu sync.Mutex
|
||||
pendingCrashSequence atomic.Uint64
|
||||
)
|
||||
|
||||
func pendingCrashPath() string {
|
||||
return filepath.Join(config.MemoryUserDir(), pendingCrashFile)
|
||||
}
|
||||
|
||||
func pendingCrashDir() string {
|
||||
return filepath.Join(config.MemoryUserDir(), pendingCrashQueueDir)
|
||||
}
|
||||
|
||||
// recoverToPending records a panicking goroutine to the pending-crash file and
|
||||
// re-raises, so the process still crashes exactly as before — the stack is now
|
||||
// shipped next launch instead of lost.
|
||||
func (a *App) recoverToPending(site string) {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
return
|
||||
}
|
||||
writePendingCrash(site, r, debug.Stack())
|
||||
panic(r)
|
||||
}
|
||||
|
||||
func writePendingCrash(site string, r any, stack []byte) {
|
||||
stackText := string(stack)
|
||||
msg := sanitizeCrashText(fmt.Sprintf("[go panic] %s\n\n%s", site, stackText), maxCrashDetailBytes)
|
||||
report := baseCrashReport("crash")
|
||||
report.SchemaVersion = 2
|
||||
report.Source = "go"
|
||||
report.Label = sanitizeCrashField(site, 64)
|
||||
report.ErrorType = sanitizeCrashField(fmt.Sprintf("%T", r), 128)
|
||||
report.ErrorMessage = sanitizeCrashText("Go panic captured at "+site+".", maxCrashFieldBytes)
|
||||
report.Stack = sanitizeCrashText(stackText, maxCrashStackBytes)
|
||||
report.TopFrame = topFrameFromStack(report.Stack)
|
||||
report.Message = msg
|
||||
if writePendingReport(report, true) {
|
||||
markFatalCrashCovered()
|
||||
}
|
||||
}
|
||||
|
||||
func writePendingReport(report crashReport, overwrite bool) bool {
|
||||
_ = overwrite // retained for source compatibility; the queue never overwrites.
|
||||
body, err := json.Marshal(report)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
dir := pendingCrashDir()
|
||||
if os.MkdirAll(dir, 0o700) != nil {
|
||||
return false
|
||||
}
|
||||
pendingCrashMu.Lock()
|
||||
defer pendingCrashMu.Unlock()
|
||||
name := strconv.FormatInt(time.Now().UTC().UnixNano(), 10) + "-" +
|
||||
strconv.Itoa(os.Getpid()) + "-" +
|
||||
strconv.FormatUint(pendingCrashSequence.Add(1), 10) + ".json"
|
||||
path := filepath.Join(dir, name)
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, err := f.Write(body)
|
||||
closeErr := f.Close()
|
||||
if err != nil || closeErr != nil || n != len(body) {
|
||||
_ = os.Remove(path)
|
||||
return false
|
||||
}
|
||||
paths := pendingCrashQueuePaths()
|
||||
for len(paths) > maxPendingCrashes {
|
||||
_ = os.Remove(paths[0])
|
||||
paths = paths[1:]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pendingCrashQueuePaths() []string {
|
||||
entries, err := os.ReadDir(pendingCrashDir())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
paths := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && filepath.Ext(entry.Name()) == ".json" {
|
||||
paths = append(paths, filepath.Join(pendingCrashDir(), entry.Name()))
|
||||
}
|
||||
}
|
||||
sort.Strings(paths)
|
||||
return paths
|
||||
}
|
||||
|
||||
func pendingCrashPaths() []string {
|
||||
paths := make([]string, 0, maxPendingCrashes+1)
|
||||
if _, err := os.Stat(pendingCrashPath()); err == nil {
|
||||
paths = append(paths, pendingCrashPath())
|
||||
}
|
||||
return append(paths, pendingCrashQueuePaths()...)
|
||||
}
|
||||
|
||||
func removeAllPendingCrashes() {
|
||||
_ = os.Remove(pendingCrashPath())
|
||||
_ = os.RemoveAll(pendingCrashDir())
|
||||
_ = os.Remove(fatalCrashCoveredPath())
|
||||
}
|
||||
|
||||
func (a *App) goSafe(site string, fn func()) {
|
||||
go func() {
|
||||
defer a.recoverToPending(site)
|
||||
fn()
|
||||
}()
|
||||
}
|
||||
|
||||
// flushPendingCrash drains a Go panic captured on a prior run and POSTs it, then
|
||||
// clears it. Runs at launch alongside the ping; honours the telemetry opt-out by
|
||||
// dropping the file unsent.
|
||||
func (a *App) flushPendingCrash() {
|
||||
if version == "dev" {
|
||||
return
|
||||
}
|
||||
paths := pendingCrashPaths()
|
||||
if len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if !cfg.DesktopTelemetry() {
|
||||
removeAllPendingCrashes()
|
||||
return
|
||||
}
|
||||
c, err := httpClient()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, path := range paths {
|
||||
body, readErr := readFileUTF8(path)
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
var r crashReport
|
||||
if json.Unmarshal(body, &r) != nil {
|
||||
_ = os.Remove(path)
|
||||
continue
|
||||
}
|
||||
if postCrashReport(a.bootContext(), c, crashEndpoint, r) != nil {
|
||||
break
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
}
|
||||
_ = os.Remove(pendingCrashDir())
|
||||
}
|
||||
@@ -1,247 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readPending(t *testing.T) (crashReport, bool) {
|
||||
t.Helper()
|
||||
paths := pendingCrashPaths()
|
||||
if len(paths) == 0 {
|
||||
return crashReport{}, false
|
||||
}
|
||||
body, err := os.ReadFile(paths[0])
|
||||
if err != nil {
|
||||
return crashReport{}, false
|
||||
}
|
||||
var r crashReport
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
t.Fatalf("pending file not valid JSON: %v", err)
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
func TestRecoverToPendingCapturesAndReraises(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
t.Fatal("recoverToPending must re-raise the panic")
|
||||
}
|
||||
}()
|
||||
app := NewApp()
|
||||
defer app.recoverToPending("unit")
|
||||
panic(`boom at C:\Users\alice\proj\x.go`)
|
||||
}()
|
||||
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected a pending crash file")
|
||||
}
|
||||
if r.Kind != "crash" {
|
||||
t.Errorf("kind = %q, want crash", r.Kind)
|
||||
}
|
||||
if !strings.Contains(r.Message, "[go panic] unit") {
|
||||
t.Errorf("message missing site prefix: %q", r.Message)
|
||||
}
|
||||
if r.Source != "go" || r.Label != "unit" || r.ErrorMessage == "" || r.Stack == "" || r.TopFrame == "" {
|
||||
t.Errorf("structured panic metadata missing: %+v", r)
|
||||
}
|
||||
if strings.Contains(r.Message, `Users\alice`) {
|
||||
t.Errorf("message not scrubbed: %q", r.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashCaps(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
writePendingCrash("big", "x", []byte(strings.Repeat("a", 64<<10)))
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected a pending crash file")
|
||||
}
|
||||
if len(r.Message) > maxCrashDetailBytes {
|
||||
t.Errorf("message len = %d, want <= %d", len(r.Message), maxCrashDetailBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportQueuesWithoutOverwritingExistingCrash(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
writePendingCrash("panic", "boom", []byte("stack"))
|
||||
before, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected initial pending crash")
|
||||
}
|
||||
|
||||
hang := baseCrashReport("performance")
|
||||
hang.Source = "native.watchdog"
|
||||
hang.Label = "mac.main_thread.hang"
|
||||
hang.Message = "hang"
|
||||
if !writePendingReport(hang, false) {
|
||||
t.Fatal("writePendingReport should enqueue the second report")
|
||||
}
|
||||
after, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected pending crash after skipped write")
|
||||
}
|
||||
if after.Label != before.Label || after.Message != before.Message {
|
||||
t.Fatalf("pending crash was overwritten: before=%+v after=%+v", before, after)
|
||||
}
|
||||
if got := len(pendingCrashPaths()); got != 2 {
|
||||
t.Fatalf("pending reports = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingReportQueueIsBoundedUnderConcurrentWriters(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
const writers = 32
|
||||
start := make(chan struct{})
|
||||
var ready sync.WaitGroup
|
||||
var done sync.WaitGroup
|
||||
var successes atomic.Int32
|
||||
|
||||
for range writers {
|
||||
ready.Add(1)
|
||||
done.Go(func() {
|
||||
report := baseCrashReport("performance")
|
||||
report.Source = "native.watchdog"
|
||||
report.Label = "mac.main_thread.hang"
|
||||
report.Message = strings.Repeat("hang", 1024)
|
||||
ready.Done()
|
||||
<-start
|
||||
if writePendingReport(report, false) {
|
||||
successes.Add(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
done.Wait()
|
||||
|
||||
if got := successes.Load(); got != writers {
|
||||
t.Fatalf("successful queued writers = %d, want %d", got, writers)
|
||||
}
|
||||
if got := len(pendingCrashPaths()); got != maxPendingCrashes {
|
||||
t.Fatalf("pending reports = %d, want bounded queue of %d", got, maxPendingCrashes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWritePendingCrashScrubsSensitiveText(t *testing.T) {
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
apiKey := "sk-proj-" + "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
bearer := "abcdefghijklmnopqrstuvwxyz1234567890ABCDE"
|
||||
longHex := "0123456789abcdef0123456789abcdef"
|
||||
|
||||
privatePanicValue := "private user prompt contents"
|
||||
writePendingCrash("unit", privatePanicValue+" api_key="+apiKey+" user [email protected]", []byte("goroutine\nAuthorization: Bearer "+bearer+"\n/home/alice/project/x.go:12\nhash "+longHex))
|
||||
r, ok := readPending(t)
|
||||
if !ok {
|
||||
t.Fatal("expected a pending crash file")
|
||||
}
|
||||
freeText := strings.Join([]string{r.Message, r.ErrorMessage, r.Stack, r.TopFrame}, "\n")
|
||||
for _, leaked := range []string{privatePanicValue, apiKey, bearer, longHex, "[email protected]", "/home/alice"} {
|
||||
if strings.Contains(freeText, leaked) {
|
||||
t.Fatalf("sensitive value leaked %q in %+v", leaked, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashSendsAndClears(t *testing.T) {
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version, crashEndpoint = oldVersion, oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
|
||||
writePendingCrash("flush", "boom", []byte("stack"))
|
||||
NewApp().flushPendingCrash()
|
||||
|
||||
if hits.Load() != 1 {
|
||||
t.Errorf("server hits = %d, want 1", hits.Load())
|
||||
}
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Error("pending file should be cleared after a successful send")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashDevGuard(t *testing.T) {
|
||||
oldVersion := version
|
||||
t.Cleanup(func() {
|
||||
version = oldVersion
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "dev"
|
||||
|
||||
writePendingCrash("dev", "boom", []byte("stack"))
|
||||
NewApp().flushPendingCrash()
|
||||
|
||||
if _, ok := readPending(t); !ok {
|
||||
t.Error("dev build must leave the pending file untouched")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlushPendingCrashIgnoresSafeModeEnv(t *testing.T) {
|
||||
// v1.20+: REASONIX_SAFE_MODE no longer blocks crash flush.
|
||||
t.Setenv("REASONIX_SAFE_MODE", "1")
|
||||
oldVersion, oldEndpoint := version, crashEndpoint
|
||||
t.Cleanup(func() {
|
||||
version = oldVersion
|
||||
crashEndpoint = oldEndpoint
|
||||
removeAllPendingCrashes()
|
||||
})
|
||||
version = "v9.9.9"
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
hits.Add(1)
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
}))
|
||||
defer srv.Close()
|
||||
crashEndpoint = srv.URL
|
||||
|
||||
writePendingCrash("safe", "boom", []byte("stack"))
|
||||
NewApp().flushPendingCrash()
|
||||
if hits.Load() != 1 {
|
||||
t.Fatalf("server hits = %d, want 1", hits.Load())
|
||||
}
|
||||
if _, ok := readPending(t); ok {
|
||||
t.Fatal("safe-mode compatibility left a sent report pending")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingCrashDoesNotPersistInstallID(t *testing.T) {
|
||||
removeAllPendingCrashes()
|
||||
t.Cleanup(removeAllPendingCrashes)
|
||||
report := baseCrashReport("crash")
|
||||
report.Message = "pending"
|
||||
if !writePendingReport(report, true) {
|
||||
t.Fatal("writePendingReport failed")
|
||||
}
|
||||
paths := pendingCrashQueuePaths()
|
||||
if len(paths) != 1 {
|
||||
t.Fatalf("pending paths = %v", paths)
|
||||
}
|
||||
body, err := os.ReadFile(paths[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if bytes.Contains(body, []byte("installId")) || bytes.Contains(body, []byte("install-id")) {
|
||||
t.Fatalf("pending report persisted an installation identity: %s", body)
|
||||
}
|
||||
}
|
||||
@@ -1,473 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/secrets"
|
||||
)
|
||||
|
||||
// deferredRebuildRetryInterval is how often the retry loop probes a held
|
||||
// session lease. Package-level so tests can shorten it.
|
||||
var deferredRebuildRetryInterval = 2 * time.Second
|
||||
|
||||
const deferredStartupBuildLabel = "__startup__"
|
||||
|
||||
// deferredRuntimeReloadLabel marks a queued ReloadRuntime in the pending map.
|
||||
// Like the startup label it is not a user setting name; the retry loop routes
|
||||
// it to the boot.Rebuild reload path instead of a settings rebuild.
|
||||
const deferredRuntimeReloadLabel = "__reload__"
|
||||
|
||||
// deferredRebuildState tracks tabs whose settings were saved to disk but whose
|
||||
// runtime could not refresh, plus tabs whose initial startup failed, because
|
||||
// the session lease was held by another Reasonix process. A single background
|
||||
// loop probes the lease and replays the rebuild once the other side releases
|
||||
// it. The loop only runs after enableDeferredRebuildRetry (the wails startup
|
||||
// hook); tests that never call it get the pending bookkeeping without a
|
||||
// background goroutine.
|
||||
type deferredRebuildState struct {
|
||||
mu sync.Mutex
|
||||
pending map[string]string // tab ID -> setting label for notices
|
||||
enabled bool
|
||||
running bool
|
||||
stopped bool
|
||||
stop chan struct{}
|
||||
}
|
||||
|
||||
// enableDeferredRebuildRetry arms the retry loop; called from startup.
|
||||
func (a *App) enableDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.enabled = true
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
// startDeferredRebuildLoopLocked starts the loop when it is armed, idle, and
|
||||
// has work. Callers must hold d.mu.
|
||||
func (a *App) startDeferredRebuildLoopLocked() {
|
||||
d := &a.deferredRebuild
|
||||
if !d.enabled || d.running || d.stopped || len(d.pending) == 0 {
|
||||
return
|
||||
}
|
||||
d.running = true
|
||||
if d.stop == nil {
|
||||
d.stop = make(chan struct{})
|
||||
}
|
||||
go a.deferredRebuildLoop(d.stop)
|
||||
}
|
||||
|
||||
// scheduleDeferredRebuild records that tabID needs a runtime refresh for
|
||||
// setting and starts the retry loop if it is not running yet. Repeated calls
|
||||
// for the same tab collapse into one retry carrying the latest label.
|
||||
func (a *App) scheduleDeferredRebuild(tabID, setting string) {
|
||||
tabID = strings.TrimSpace(tabID)
|
||||
if tabID == "" {
|
||||
return
|
||||
}
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
if d.pending == nil {
|
||||
d.pending = map[string]string{}
|
||||
}
|
||||
d.pending[tabID] = setting
|
||||
a.startDeferredRebuildLoopLocked()
|
||||
}
|
||||
|
||||
func (a *App) scheduleDeferredStartupBuild(tabID string) {
|
||||
a.scheduleDeferredRebuild(tabID, deferredStartupBuildLabel)
|
||||
}
|
||||
|
||||
func isDeferredStartupBuild(setting string) bool {
|
||||
return setting == deferredStartupBuildLabel
|
||||
}
|
||||
|
||||
func isDeferredRuntimeReload(setting string) bool {
|
||||
return setting == deferredRuntimeReloadLabel
|
||||
}
|
||||
|
||||
func (a *App) clearDeferredRebuild(tabID string) {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
delete(d.pending, tabID)
|
||||
d.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildPending(tabID string) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
_, ok := d.pending[tabID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// stopDeferredRebuildRetry permanently stops the retry loop; used on shutdown
|
||||
// and by tests.
|
||||
func (a *App) stopDeferredRebuildRetry() {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if d.stopped {
|
||||
return
|
||||
}
|
||||
d.stopped = true
|
||||
if d.stop != nil {
|
||||
close(d.stop)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildLoop(stop <-chan struct{}) {
|
||||
ticker := time.NewTicker(deferredRebuildRetryInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
d.running = false
|
||||
d.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
if a.deferredRebuildTickDone() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// deferredRebuildTickDone runs one retry pass and reports true when the loop
|
||||
// should exit because nothing is pending anymore.
|
||||
func (a *App) deferredRebuildTickDone() bool {
|
||||
return a.deferredRebuildTick(true)
|
||||
}
|
||||
|
||||
func (a *App) deferredRebuildTick(markIdle bool) bool {
|
||||
d := &a.deferredRebuild
|
||||
d.mu.Lock()
|
||||
if d.stopped || len(d.pending) == 0 {
|
||||
if markIdle {
|
||||
d.running = false
|
||||
}
|
||||
d.mu.Unlock()
|
||||
return true
|
||||
}
|
||||
pending := make(map[string]string, len(d.pending))
|
||||
maps.Copy(pending, d.pending)
|
||||
d.mu.Unlock()
|
||||
|
||||
for tabID, setting := range pending {
|
||||
a.retryDeferredRebuild(tabID, setting)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *App) kickDeferredRebuildRetry() {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
a.goSafe("deferredRebuildKick", func() {
|
||||
_ = a.deferredRebuildTick(false)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredRebuild(tabID, setting string) {
|
||||
if a.ctx == nil {
|
||||
return
|
||||
}
|
||||
tab := a.tabByID(tabID)
|
||||
if tab == nil || tab.ID != tabID {
|
||||
// The tab is gone; nothing left to refresh.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if isDeferredStartupBuild(setting) {
|
||||
a.retryDeferredStartupBuild(tabID, tab)
|
||||
return
|
||||
}
|
||||
if isDeferredRuntimeReload(setting) {
|
||||
a.retryDeferredRuntimeReload(tabID, tab)
|
||||
return
|
||||
}
|
||||
// Hold the rebuild mutex across probe + rebuild: the probe briefly acquires
|
||||
// the session lease, and a concurrent manual rebuild's ensureSessionLease
|
||||
// would see that probe as "held by another runtime" and spuriously defer.
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
// rebuildSettingLocked refreshes the active tab only. Wait until the user
|
||||
// is back on this tab so we refresh the runtime the pending setting was
|
||||
// meant for, not whichever tab happens to be focused.
|
||||
if a.activeTab() != tab {
|
||||
return
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// Mid-(re)build on another path (provider retarget, workspace repair);
|
||||
// racing a second build+swap against it is what this loop must avoid.
|
||||
return
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(ctrl) {
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildSettingLocked(setting)
|
||||
if err == nil {
|
||||
// rebuildSettingLocked already cleared the pending entry for the tab it
|
||||
// refreshed; just announce it.
|
||||
a.noticeForTab(tabID, fmt.Sprintf("%s applied: session refreshed after the lease was released", setting))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return // grabbed back before we could rebuild; keep waiting
|
||||
}
|
||||
var busy *rebuildBusyError
|
||||
if errors.As(err, &busy) {
|
||||
return // a turn started meanwhile; retry once it finishes
|
||||
}
|
||||
// Anything else will not resolve by waiting; give up loudly instead of
|
||||
// retrying forever.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred settings rebuild failed", "setting", setting, "tab", tabID, "err", err)
|
||||
a.warnForTab(tabID, fmt.Sprintf("%s was saved but the session could not refresh: %s", setting, err.Error()))
|
||||
}
|
||||
|
||||
// retryDeferredRuntimeReload drives one queued ReloadRuntime pass. The
|
||||
// probing contract mirrors retryDeferredRebuild: wait for the tab to be
|
||||
// active and idle and for its lease to look free, then run the boot.Rebuild
|
||||
// reload; busy/lease answers keep waiting, anything else gives up loudly.
|
||||
func (a *App) retryDeferredRuntimeReload(tabID string, tab *WorkspaceTab) {
|
||||
// Hold the rebuild mutex across probe + reload: the probe briefly
|
||||
// acquires the session lease, and a concurrent rebuild's ensure lease
|
||||
// would read that probe as "held by another runtime" and spuriously
|
||||
// defer (same contract as retryDeferredRebuild).
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
// The reload shares rebuildSettingTurnLocked, whose bookkeeping is written
|
||||
// for the active tab; wait until the user is back on this tab rather than
|
||||
// refreshing whichever tab happens to be focused.
|
||||
if a.activeTab() != tab {
|
||||
return
|
||||
}
|
||||
ctrl := a.controllerForTab(tab)
|
||||
if ctrl == nil {
|
||||
// Mid-(re)build on another path; racing a second build+swap against it
|
||||
// is what this loop must avoid.
|
||||
return
|
||||
}
|
||||
if controllerHasActiveRuntimeWork(ctrl) {
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.reloadRuntimeTurnLocked(tab)
|
||||
if err == nil {
|
||||
// rebuildSettingTurnLocked already cleared the pending entry for the
|
||||
// tab it refreshed; just announce it.
|
||||
a.noticeForTab(tabID, "runtime reloaded after the session went idle")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return // grabbed back before we could reload; keep waiting
|
||||
}
|
||||
var busy *rebuildBusyError
|
||||
if errors.As(err, &busy) {
|
||||
return // a turn started meanwhile; retry once it finishes
|
||||
}
|
||||
// Anything else will not resolve by waiting; give up loudly instead of
|
||||
// retrying forever. The error may come from provider/config plumbing and
|
||||
// carry credential-shaped values (passwords, resolved API keys) — the
|
||||
// tested helper redacts before the text reaches logs or the frontend.
|
||||
a.clearDeferredRebuild(tabID)
|
||||
failure := deferredReloadFailedText(err)
|
||||
slog.Warn("desktop: "+failure, "tab", tabID)
|
||||
a.warnForTab(tabID, failure)
|
||||
}
|
||||
|
||||
// deferredReloadFailedText is the failure line for an unrecoverable deferred
|
||||
// reload — the single formatter both the log and the tab warning use, so a
|
||||
// credential-shaped error can never reach either sink unredacted.
|
||||
func deferredReloadFailedText(err error) string {
|
||||
return "runtime reload failed: " + secrets.RedactCredentials(err.Error())
|
||||
}
|
||||
|
||||
func (a *App) retryDeferredStartupBuild(tabID string, tab *WorkspaceTab) {
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
a.mu.RLock()
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if path != "" && a.attachExistingSessionRuntime(tab, path, a.ctx) {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tabID)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
return
|
||||
}
|
||||
a.clearDeferredRebuild(tabID)
|
||||
slog.Warn("desktop: deferred session startup failed", "tab", tabID, "err", err)
|
||||
}
|
||||
|
||||
func (a *App) tabHasRetryableStartupLeaseError(tab *WorkspaceTab) bool {
|
||||
if tab == nil {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.tabs[tab.ID] == tab && !tab.removed && tab.Ctrl == nil && tab.StartupErrLeaseHeld
|
||||
}
|
||||
|
||||
func (a *App) rebuildStartupTabLocked(tab *WorkspaceTab) error {
|
||||
buildCtx, cancel := context.WithCancel(a.bootContext())
|
||||
a.mu.Lock()
|
||||
if tab == nil || a.tabs[tab.ID] != tab || tab.removed {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if tab.Ctrl != nil {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
if !tab.StartupErrLeaseHeld {
|
||||
a.mu.Unlock()
|
||||
cancel()
|
||||
return nil
|
||||
}
|
||||
tab.buildGeneration++
|
||||
generation := tab.buildGeneration
|
||||
if tab.buildCancel != nil {
|
||||
tab.buildCancel()
|
||||
}
|
||||
tab.buildCancel = cancel
|
||||
tab.Ready = false
|
||||
clearTabStartupError(tab)
|
||||
a.setSessionRuntimePhaseLocked(tab, sessionRuntimeStarting, nil)
|
||||
tab.ActivityStatus = ""
|
||||
if tab.sink == nil {
|
||||
tab.sink = &tabEventSink{tabID: tab.ID, app: a, ctx: a.ctx}
|
||||
}
|
||||
a.saveTabsLocked()
|
||||
a.mu.Unlock()
|
||||
|
||||
a.buildTabControllerWithContext(tab, loadedTabSession{}, buildCtx, generation, cancel)
|
||||
|
||||
a.mu.RLock()
|
||||
stillCurrent := false
|
||||
var ctrl control.SessionAPI
|
||||
startupErr := ""
|
||||
leaseHeld := false
|
||||
if tab != nil {
|
||||
stillCurrent = a.tabs[tab.ID] == tab && !tab.removed
|
||||
ctrl = tab.Ctrl
|
||||
startupErr = tab.StartupErr
|
||||
leaseHeld = tab.StartupErrLeaseHeld
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if !stillCurrent || ctrl != nil {
|
||||
return nil
|
||||
}
|
||||
if leaseHeld {
|
||||
return agent.ErrSessionLeaseHeld
|
||||
}
|
||||
if strings.TrimSpace(startupErr) != "" {
|
||||
return fmt.Errorf("session startup: %s", startupErr)
|
||||
}
|
||||
return fmt.Errorf("session startup: controller was not built")
|
||||
}
|
||||
|
||||
func (a *App) tryRecoverStartupLeaseHeldTab(tab *WorkspaceTab) bool {
|
||||
if a.ctx == nil || !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return false
|
||||
}
|
||||
a.runtimeRebuildMu.Lock()
|
||||
defer a.runtimeRebuildMu.Unlock()
|
||||
if !a.tabHasRetryableStartupLeaseError(tab) {
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if !a.deferredRebuildLeaseLooksFree(tab) {
|
||||
return false
|
||||
}
|
||||
err := a.rebuildStartupTabLocked(tab)
|
||||
if err == nil {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
return a.controllerForTab(tab) != nil
|
||||
}
|
||||
if errors.Is(err, agent.ErrSessionLeaseHeld) {
|
||||
a.scheduleDeferredStartupBuild(tab.ID)
|
||||
} else {
|
||||
a.clearDeferredRebuild(tab.ID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// deferredRebuildLeaseLooksFree cheaply probes whether the tab's session lease
|
||||
// could be acquired right now, without touching tab.sessionLease (only the
|
||||
// serialized rebuild paths may mutate that). The probe path can lag the
|
||||
// reconciled path the rebuild will use; a stale answer either re-defers on the
|
||||
// next tick or lets the rebuild fail back into the pending set, so a mismatch
|
||||
// only delays the retry.
|
||||
func (a *App) deferredRebuildLeaseLooksFree(tab *WorkspaceTab) bool {
|
||||
a.mu.RLock()
|
||||
ctrl := tab.Ctrl
|
||||
path := strings.TrimSpace(tab.SessionPath)
|
||||
a.mu.RUnlock()
|
||||
if ctrl != nil {
|
||||
if p := strings.TrimSpace(ctrl.SessionPath()); p != "" {
|
||||
path = p
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
return true // nothing to probe; let the rebuild decide
|
||||
}
|
||||
key := sessionRuntimeKey(path)
|
||||
a.mu.RLock()
|
||||
rt := a.runtimeBySessionKey[key]
|
||||
ownedByTab := rt != nil && rt.Owner == tab
|
||||
ownedByOther := rt != nil && rt.Owner != nil && rt.Owner != tab && a.runtimeOwnerLiveLocked(rt)
|
||||
a.mu.RUnlock()
|
||||
if ownedByOther {
|
||||
return false
|
||||
}
|
||||
if ownedByTab && tab.sessionLeaseRuntimeKey() == key {
|
||||
return true
|
||||
}
|
||||
lease, err := agent.TryAcquireSessionLease(key)
|
||||
if err != nil {
|
||||
if sameCurrentProcessLease(err) {
|
||||
// The registry ruled out a live sibling owner above, so this is an
|
||||
// orphaned current-process lease. Let the rebuild helper reclaim it
|
||||
// under runtimeRebuildMu instead of looping against our own marker.
|
||||
return true
|
||||
}
|
||||
return !errors.Is(err, agent.ErrSessionLeaseHeld)
|
||||
}
|
||||
lease.Release()
|
||||
return true
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDeferredReloadFailedTextRedactsCredentials is the regression for the
|
||||
// CodeQL credential-disclosure finding: a deferred-reload failure may carry
|
||||
// provider error text containing passwords or resolved API keys, and the
|
||||
// user-visible notice must never repeat them.
|
||||
func TestDeferredReloadFailedTextRedactsCredentials(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
err error
|
||||
secret string
|
||||
}{
|
||||
{"password field", errors.New(`provider auth failed: password=hunter2hunter2`), "hunter2hunter2"},
|
||||
{"api key assignment", errors.New(`401 unauthorized: api_key=sk-abcdef1234567890SECRETKEY`), "sk-abcdef1234567890SECRETKEY"},
|
||||
{"bearer token", errors.New(`upstream rejected: Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U`), "eyJhbGciOiJIUzI1NiJ9"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
text := deferredReloadFailedText(tc.err)
|
||||
if strings.Contains(text, tc.secret) {
|
||||
t.Fatalf("notice leaks the credential: %q", text)
|
||||
}
|
||||
if !strings.HasPrefix(text, "runtime reload failed: ") {
|
||||
t.Fatalf("notice lost its context prefix: %q", text)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
var (
|
||||
inspectDeliveryWorktree = worktree.Inspect
|
||||
createDeliveryWorktree = worktree.Create
|
||||
)
|
||||
|
||||
// DeliveryWorktreeOpenResult is returned after an isolated Git workspace has
|
||||
// been created and opened as a normal Reasonix project.
|
||||
type DeliveryWorktreeOpenResult struct {
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
WorktreeRoot string `json:"worktreeRoot"`
|
||||
SourceRoot string `json:"sourceRoot"`
|
||||
Branch string `json:"branch"`
|
||||
SourceDirty bool `json:"sourceDirty"`
|
||||
Tab TabMeta `json:"tab"`
|
||||
}
|
||||
|
||||
// DeliveryWorktreeAvailability reports whether workspaceRoot can use the
|
||||
// optional Git isolation path. A false result never disables Delivery itself;
|
||||
// the cross-platform workspace writer lease remains the no-Git fallback.
|
||||
func (a *App) DeliveryWorktreeAvailability(workspaceRoot string) worktree.Availability {
|
||||
return inspectDeliveryWorktree(a.bootContext(), workspaceRoot)
|
||||
}
|
||||
|
||||
// CreateDeliveryWorktree creates a durable branch-backed worktree and opens it
|
||||
// as a project. It never switches or modifies the source checkout, and it does
|
||||
// not delete the new worktree if later UI registration fails.
|
||||
func (a *App) CreateDeliveryWorktree(workspaceRoot string) (DeliveryWorktreeOpenResult, error) {
|
||||
workspaceRoot = strings.TrimSpace(workspaceRoot)
|
||||
created, err := createDeliveryWorktree(a.bootContext(), workspaceRoot, config.DeliveryWorktreeDir())
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, err
|
||||
}
|
||||
|
||||
var tab TabMeta
|
||||
if a.singleSurfaceLayoutEnabled() {
|
||||
tab, err = a.ensureBlankSurface("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
} else {
|
||||
tab, err = a.ensureBlankTab("project", created.WorkspaceRoot, boot.TokenModeDelivery)
|
||||
}
|
||||
if err != nil {
|
||||
return DeliveryWorktreeOpenResult{}, fmt.Errorf("isolated worktree was created at %s but Reasonix could not open it: %w", created.WorktreeRoot, err)
|
||||
}
|
||||
return DeliveryWorktreeOpenResult{
|
||||
WorkspaceRoot: created.WorkspaceRoot,
|
||||
WorktreeRoot: created.WorktreeRoot,
|
||||
SourceRoot: created.SourceRoot,
|
||||
Branch: created.Branch,
|
||||
SourceDirty: created.SourceDirty,
|
||||
Tab: tab,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/worktree"
|
||||
)
|
||||
|
||||
func TestDeliveryWorktreeAvailabilityDelegatesWithoutRequiringGit(t *testing.T) {
|
||||
original := inspectDeliveryWorktree
|
||||
t.Cleanup(func() { inspectDeliveryWorktree = original })
|
||||
inspectDeliveryWorktree = func(_ context.Context, root string) worktree.Availability {
|
||||
return worktree.Availability{Available: false, Reason: "Git is not installed", RepoRoot: root}
|
||||
}
|
||||
got := NewApp().DeliveryWorktreeAvailability("project")
|
||||
if got.Available || got.Reason != "Git is not installed" || got.RepoRoot != "project" {
|
||||
t.Fatalf("availability = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDeliveryWorktreeRegistersAndOpensManagedProject(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
t.Setenv("REASONIX_STATE_HOME", home)
|
||||
t.Setenv("REASONIX_CACHE_HOME", filepath.Join(home, "cache"))
|
||||
managed := config.DeliveryWorktreeDir()
|
||||
isolatedRoot := filepath.Join(managed, "repo", "id", "project")
|
||||
if err := os.MkdirAll(isolatedRoot, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
original := createDeliveryWorktree
|
||||
t.Cleanup(func() { createDeliveryWorktree = original })
|
||||
createDeliveryWorktree = func(_ context.Context, source, gotManaged string) (worktree.Result, error) {
|
||||
if source != "source-project" {
|
||||
t.Fatalf("source = %q", source)
|
||||
}
|
||||
if gotManaged != managed {
|
||||
t.Fatalf("managed root = %q, want %q", gotManaged, managed)
|
||||
}
|
||||
return worktree.Result{
|
||||
WorkspaceRoot: isolatedRoot,
|
||||
WorktreeRoot: filepath.Dir(isolatedRoot),
|
||||
SourceRoot: "source-project",
|
||||
Branch: "reasonix/delivery-test",
|
||||
SourceDirty: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
t.Cleanup(func() { app.shutdown(context.Background()) })
|
||||
result, err := app.CreateDeliveryWorktree("source-project")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.WorkspaceRoot != isolatedRoot || result.Branch != "reasonix/delivery-test" || !result.SourceDirty {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
if result.Tab.WorkspaceRoot != isolatedRoot || !result.Tab.IsolatedWorktree || !result.Tab.Active || result.Tab.TokenMode != "delivery" {
|
||||
t.Fatalf("opened tab = %+v", result.Tab)
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package main
|
||||
|
||||
func initialDesktopWindowSize() (int, int) {
|
||||
width, height := 1240, 720
|
||||
if saved, ok := loadWindowState(); ok {
|
||||
if saved.Width > 0 {
|
||||
width = saved.Width
|
||||
}
|
||||
if saved.Height > 0 {
|
||||
height = saved.Height
|
||||
}
|
||||
}
|
||||
return width, height
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// devinfo.go collects coarse machine facts attached to crash reports: OS version,
|
||||
// CPU model, core count, RAM. Nothing here identifies a user or machine.
|
||||
|
||||
type deviceInfo struct {
|
||||
OSVersion string `json:"osVersion,omitempty"`
|
||||
OSBuild int `json:"osBuild,omitempty"`
|
||||
OSRevision int `json:"osRevision,omitempty"`
|
||||
DistroID string `json:"distroId,omitempty"`
|
||||
DistroVersion string `json:"distroVersion,omitempty"`
|
||||
KernelVersion string `json:"kernelVersion,omitempty"`
|
||||
SessionType string `json:"sessionType,omitempty"`
|
||||
CPU string `json:"cpu,omitempty"`
|
||||
Cores int `json:"cores"`
|
||||
RAMGB int `json:"ramGb,omitempty"`
|
||||
}
|
||||
|
||||
type platformEnvironment struct {
|
||||
DistroID string
|
||||
DistroVersion string
|
||||
KernelVersion string
|
||||
SessionType string
|
||||
}
|
||||
|
||||
const gib = 1 << 30
|
||||
|
||||
func collectDeviceInfo() deviceInfo {
|
||||
osBuild, osRevision := platformOSBuild()
|
||||
environment := platformEnvironmentInfo()
|
||||
return deviceInfo{
|
||||
OSVersion: boundedPlatformField(platformOSVersion(), 128),
|
||||
OSBuild: osBuild,
|
||||
OSRevision: osRevision,
|
||||
DistroID: environment.DistroID,
|
||||
DistroVersion: environment.DistroVersion,
|
||||
KernelVersion: environment.KernelVersion,
|
||||
SessionType: environment.SessionType,
|
||||
CPU: boundedPlatformField(platformCPU(), 128),
|
||||
Cores: runtime.NumCPU(),
|
||||
RAMGB: int((platformRAMBytes() + gib/2) / gib),
|
||||
}
|
||||
}
|
||||
|
||||
func boundedPlatformField(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r < 0x20 || r == 0x7f {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
if len(value) > limit {
|
||||
value = value[:limit]
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func parseOSRelease(osRelease string) map[string]string {
|
||||
result := make(map[string]string)
|
||||
for line := range strings.SplitSeq(osRelease, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
|
||||
value = value[1 : len(value)-1]
|
||||
value = strings.ReplaceAll(value, `\"`, `"`)
|
||||
value = strings.ReplaceAll(value, `\\`, `\`)
|
||||
}
|
||||
result[key] = value
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseCPUModel(cpuinfo string) string {
|
||||
for line := range strings.SplitSeq(cpuinfo, "\n") {
|
||||
if name, ok := strings.CutPrefix(line, "model name"); ok {
|
||||
if _, v, ok := strings.Cut(name, ":"); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseMemTotalBytes(meminfo string) uint64 {
|
||||
for line := range strings.SplitSeq(meminfo, "\n") {
|
||||
if rest, ok := strings.CutPrefix(line, "MemTotal:"); ok {
|
||||
kb, err := strconv.ParseUint(strings.TrimSuffix(strings.TrimSpace(rest), " kB"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return kb * 1024
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseOSReleasePrettyName(osRelease string) string {
|
||||
return parseOSRelease(osRelease)["PRETTY_NAME"]
|
||||
}
|
||||
|
||||
func linuxSessionType(getenv func(string) string) string {
|
||||
if getenv("SSH_CONNECTION") != "" || getenv("XRDP_SESSION") != "" {
|
||||
return "remote"
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(getenv("XDG_SESSION_TYPE"))) {
|
||||
case "wayland":
|
||||
return "wayland"
|
||||
case "x11":
|
||||
return "x11"
|
||||
}
|
||||
if getenv("WAYLAND_DISPLAY") != "" {
|
||||
return "wayland"
|
||||
}
|
||||
if getenv("DISPLAY") != "" {
|
||||
return "x11"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func sysctlString(name string) string {
|
||||
out, err := exec.Command("sysctl", "-n", name).Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func platformOSVersion() string {
|
||||
out, err := exec.Command("sw_vers", "-productVersion").Output()
|
||||
if err != nil {
|
||||
return "macOS"
|
||||
}
|
||||
return "macOS " + strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
func platformOSBuild() (int, int) { return 0, 0 }
|
||||
|
||||
func platformEnvironmentInfo() platformEnvironment { return platformEnvironment{} }
|
||||
|
||||
func platformCPU() string {
|
||||
return sysctlString("machdep.cpu.brand_string")
|
||||
}
|
||||
|
||||
func platformRAMBytes() uint64 {
|
||||
n, err := strconv.ParseUint(sysctlString("hw.memsize"), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func readOr(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func platformOSVersion() string {
|
||||
if name := parseOSReleasePrettyName(readOr("/etc/os-release")); name != "" {
|
||||
return name
|
||||
}
|
||||
return "Linux"
|
||||
}
|
||||
|
||||
func platformOSBuild() (int, int) { return 0, 0 }
|
||||
|
||||
func platformEnvironmentInfo() platformEnvironment {
|
||||
release := parseOSRelease(readOr("/etc/os-release"))
|
||||
distroID := boundedPlatformField(strings.ToLower(release["ID"]), 64)
|
||||
if distroID == "" {
|
||||
distroID = "unknown"
|
||||
}
|
||||
return platformEnvironment{
|
||||
DistroID: distroID,
|
||||
DistroVersion: boundedPlatformField(release["VERSION_ID"], 64),
|
||||
KernelVersion: boundedPlatformField(readOr("/proc/sys/kernel/osrelease"), 128),
|
||||
SessionType: linuxSessionType(os.Getenv),
|
||||
}
|
||||
}
|
||||
|
||||
func platformCPU() string {
|
||||
return parseCPUModel(readOr("/proc/cpuinfo"))
|
||||
}
|
||||
|
||||
func platformRAMBytes() uint64 {
|
||||
return parseMemTotalBytes(readOr("/proc/meminfo"))
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseCPUModel(t *testing.T) {
|
||||
cpuinfo := "processor\t: 0\nvendor_id\t: GenuineIntel\nmodel name\t: Intel(R) Core(TM) i7-12700K\nflags\t: fpu vme\n"
|
||||
if got := parseCPUModel(cpuinfo); got != "Intel(R) Core(TM) i7-12700K" {
|
||||
t.Errorf("parseCPUModel = %q", got)
|
||||
}
|
||||
if got := parseCPUModel("no such field"); got != "" {
|
||||
t.Errorf("parseCPUModel on garbage = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMemTotalBytes(t *testing.T) {
|
||||
meminfo := "MemTotal: 32652284 kB\nMemFree: 1234 kB\n"
|
||||
if got := parseMemTotalBytes(meminfo); got != 32652284*1024 {
|
||||
t.Errorf("parseMemTotalBytes = %d", got)
|
||||
}
|
||||
if got := parseMemTotalBytes("MemFree: 1 kB"); got != 0 {
|
||||
t.Errorf("parseMemTotalBytes without MemTotal = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleasePrettyName(t *testing.T) {
|
||||
osRelease := "NAME=\"Ubuntu\"\nPRETTY_NAME=\"Ubuntu 24.04.1 LTS\"\nID=ubuntu\n"
|
||||
if got := parseOSReleasePrettyName(osRelease); got != "Ubuntu 24.04.1 LTS" {
|
||||
t.Errorf("parseOSReleasePrettyName = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleaseTechnicalFields(t *testing.T) {
|
||||
fields := parseOSRelease("# comment\nID=fedora\nVERSION_ID=\"40\"\nPRETTY_NAME='Fedora Linux 40'\nBROKEN\n")
|
||||
if fields["ID"] != "fedora" || fields["VERSION_ID"] != "40" || fields["PRETTY_NAME"] != "Fedora Linux 40" {
|
||||
t.Fatalf("os-release fields = %#v", fields)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleaseDistributionMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, body, id, version string
|
||||
}{
|
||||
{name: "ubuntu", body: "ID=ubuntu\nVERSION_ID=22.04\n", id: "ubuntu", version: "22.04"},
|
||||
{name: "debian", body: "ID=debian\nVERSION_ID=12\n", id: "debian", version: "12"},
|
||||
{name: "fedora", body: "ID=fedora\nVERSION_ID=40\n", id: "fedora", version: "40"},
|
||||
{name: "arch", body: "ID=arch\n", id: "arch"},
|
||||
{name: "unknown", body: "NAME=Custom Linux\n", id: ""},
|
||||
{name: "malformed", body: "BROKEN\n=bad\n", id: ""},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fields := parseOSRelease(test.body)
|
||||
if fields["ID"] != test.id || fields["VERSION_ID"] != test.version {
|
||||
t.Fatalf("fields = %#v", fields)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxSessionTypeUsesBoundedBuckets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
want string
|
||||
}{
|
||||
{name: "wayland", env: map[string]string{"XDG_SESSION_TYPE": "Wayland"}, want: "wayland"},
|
||||
{name: "x11 fallback", env: map[string]string{"DISPLAY": ":0"}, want: "x11"},
|
||||
{name: "remote wins", env: map[string]string{"SSH_CONNECTION": "present", "XDG_SESSION_TYPE": "wayland"}, want: "remote"},
|
||||
{name: "unknown", env: map[string]string{"XDG_SESSION_TYPE": "tty"}, want: "unknown"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := linuxSessionType(func(key string) string { return tt.env[key] }); got != tt.want {
|
||||
t.Fatalf("linuxSessionType() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectDeviceInfoSane(t *testing.T) {
|
||||
d := collectDeviceInfo()
|
||||
if d.Cores < 1 {
|
||||
t.Errorf("Cores = %d", d.Cores)
|
||||
}
|
||||
if d.OSVersion == "" {
|
||||
t.Error("OSVersion empty")
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
func platformOSVersion() string {
|
||||
v := windows.RtlGetVersion()
|
||||
return fmt.Sprintf("Windows %d.%d build %d", v.MajorVersion, v.MinorVersion, v.BuildNumber)
|
||||
}
|
||||
|
||||
func platformOSBuild() (int, int) {
|
||||
v := windows.RtlGetVersion()
|
||||
revision := 0
|
||||
if k, err := registry.OpenKey(registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\Windows NT\CurrentVersion`, registry.QUERY_VALUE); err == nil {
|
||||
defer k.Close()
|
||||
if ubr, _, readErr := k.GetIntegerValue("UBR"); readErr == nil {
|
||||
revision = int(ubr)
|
||||
}
|
||||
}
|
||||
return int(v.BuildNumber), revision
|
||||
}
|
||||
|
||||
func platformEnvironmentInfo() platformEnvironment { return platformEnvironment{} }
|
||||
|
||||
func platformCPU() string {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, `HARDWARE\DESCRIPTION\System\CentralProcessor\0`, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer k.Close()
|
||||
name, _, err := k.GetStringValue("ProcessorNameString")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(name)
|
||||
}
|
||||
|
||||
func platformRAMBytes() uint64 {
|
||||
var kb uint64
|
||||
proc := windows.NewLazySystemDLL("kernel32.dll").NewProc("GetPhysicallyInstalledSystemMemory")
|
||||
if ret, _, _ := proc.Call(uintptr(unsafe.Pointer(&kb))); ret == 0 {
|
||||
return 0
|
||||
}
|
||||
return kb * 1024
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
// The diverged-recovery marker is only useful if it survives the shared status
|
||||
// whitelist. An earlier revision set a status string the whitelist dropped, so
|
||||
// the project tree silently lost the prompt.
|
||||
func TestNormalizeTopicStatusKeepsDivergedRecovery(t *testing.T) {
|
||||
if got := normalizeTopicStatus(topicStatusDivergedRecovery); got != topicStatusDivergedRecovery {
|
||||
t.Fatalf("normalizeTopicStatus(%q) = %q, want it preserved", topicStatusDivergedRecovery, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTopicStatusStillRejectsUnknown(t *testing.T) {
|
||||
if got := normalizeTopicStatus("not_a_status"); got != "" {
|
||||
t.Fatalf("normalizeTopicStatus(unknown) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/fileutil"
|
||||
)
|
||||
|
||||
// upsertDotEnv stores KEY=value in Reasonix's global .env and applies it to the
|
||||
// running process so a rebuild picks it up without a restart.
|
||||
func upsertDotEnv(key, value string) error {
|
||||
_, err := config.SetCredential(key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
func removeDotEnv(key string) error {
|
||||
return config.RemoveCredential(key)
|
||||
}
|
||||
|
||||
// upsertEnvFile merges KEY=value into a KEY=value file at path, preserving
|
||||
// comments and unrelated lines, writing atomically via a sibling temp + rename.
|
||||
func upsertEnvFile(path, key, value string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
var lines []string
|
||||
if b, err := readFileUTF8(path); err == nil {
|
||||
lines = strings.Split(strings.TrimRight(string(b), "\n"), "\n")
|
||||
}
|
||||
replaced := false
|
||||
for i, ln := range lines {
|
||||
t := strings.TrimSpace(ln)
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
continue
|
||||
}
|
||||
if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
|
||||
lines[i] = key + "=" + value
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
lines = append(lines, key+"="+value)
|
||||
}
|
||||
out := strings.Join(lines, "\n") + "\n"
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.WriteString(out); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return os.Setenv(key, value)
|
||||
}
|
||||
|
||||
func removeEnvFile(path, key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
data, err := readFileUTF8(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return os.Unsetenv(key)
|
||||
}
|
||||
return err
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
outLines := make([]string, 0, len(lines))
|
||||
for _, ln := range lines {
|
||||
t := strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(ln), "export "))
|
||||
if t == "" || strings.HasPrefix(t, "#") {
|
||||
outLines = append(outLines, ln)
|
||||
continue
|
||||
}
|
||||
if k, _, ok := strings.Cut(t, "="); ok && strings.TrimSpace(k) == key {
|
||||
continue
|
||||
}
|
||||
outLines = append(outLines, ln)
|
||||
}
|
||||
out := ""
|
||||
if len(outLines) > 0 {
|
||||
out = strings.Join(outLines, "\n") + "\n"
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
tmp, err := os.CreateTemp(dir, "credentials.*.tmp")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpPath := tmp.Name()
|
||||
if _, err := tmp.WriteString(out); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
if err := fileutil.ReplaceFile(tmpPath, path); err != nil {
|
||||
os.Remove(tmpPath)
|
||||
return err
|
||||
}
|
||||
return os.Unsetenv(key)
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// TestUpsertEnvFile proves a new key is appended, an existing key is replaced in
|
||||
// place, comments/other lines survive, and the process env is updated.
|
||||
func TestUpsertEnvFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials")
|
||||
if err := os.WriteFile(path, []byte("# comment\nFOO=old\nBAR=keep\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := upsertEnvFile(path, "FOO", "new"); err != nil {
|
||||
t.Fatalf("replace: %v", err)
|
||||
}
|
||||
if err := upsertEnvFile(path, "BAZ", "added"); err != nil {
|
||||
t.Fatalf("append: %v", err)
|
||||
}
|
||||
|
||||
b, _ := os.ReadFile(path)
|
||||
got := string(b)
|
||||
for _, want := range []string{"# comment", "FOO=new", "BAR=keep", "BAZ=added"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "FOO=old") {
|
||||
t.Errorf("old value should be replaced:\n%s", got)
|
||||
}
|
||||
if os.Getenv("FOO") != "new" || os.Getenv("BAZ") != "added" {
|
||||
t.Errorf("process env not updated: FOO=%q BAZ=%q", os.Getenv("FOO"), os.Getenv("BAZ"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveEnvFileDeletesKeyAndUnsetsProcessEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "credentials")
|
||||
if err := os.WriteFile(path, []byte("# comment\nFOO=old\nexport BAR=remove\nBAZ=keep\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("BAR", "remove")
|
||||
|
||||
if err := removeEnvFile(path, "BAR"); err != nil {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
|
||||
b, _ := os.ReadFile(path)
|
||||
got := string(b)
|
||||
for _, want := range []string{"# comment", "FOO=old", "BAZ=keep"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "BAR=") {
|
||||
t.Errorf("removed key should be absent:\n%s", got)
|
||||
}
|
||||
if _, ok := os.LookupEnv("BAR"); ok {
|
||||
t.Errorf("process env BAR should be unset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyHomeEnvProviderKeyIsNotPromoted(t *testing.T) {
|
||||
home := isolateDesktopUserDirs(t)
|
||||
homeEnv := filepath.Join(home, ".env")
|
||||
if err := os.WriteFile(homeEnv, []byte("DEEPSEEK_API_KEY=sk-test\nNPM_TOKEN=secret\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := config.LoadForRoot(t.TempDir()); err != nil {
|
||||
t.Fatalf("LoadForRoot: %v", err)
|
||||
}
|
||||
if data, err := os.ReadFile(config.UserCredentialsPath()); err == nil && strings.Contains(string(data), "DEEPSEEK_API_KEY") {
|
||||
t.Errorf("legacy ~/.env provider key must not be imported:\n%s", data)
|
||||
}
|
||||
rest, _ := os.ReadFile(homeEnv)
|
||||
if !strings.Contains(string(rest), "DEEPSEEK_API_KEY=sk-test") || !strings.Contains(string(rest), "NPM_TOKEN=secret") {
|
||||
t.Errorf("legacy ~/.env should be left untouched:\n%s", rest)
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
fileencoding "reasonix/internal/fileutil/encoding"
|
||||
)
|
||||
|
||||
func readFileUTF8(path string) ([]byte, error) {
|
||||
return fileencoding.ReadFileUTF8(path)
|
||||
}
|
||||
|
||||
func readFileUTF8Limit(path string, limit int64) ([]byte, bool, error) {
|
||||
if limit < 0 {
|
||||
return nil, false, fmt.Errorf("invalid read limit %d", limit)
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, true, nil
|
||||
}
|
||||
decoded := fileencoding.DecodeToUTF8(raw)
|
||||
if int64(len(decoded)) > limit {
|
||||
return nil, true, nil
|
||||
}
|
||||
return decoded, false, nil
|
||||
}
|
||||
@@ -1,346 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveExportFileWritesTextAndBinaryPayloads(t *testing.T) {
|
||||
t.Parallel()
|
||||
app := &App{}
|
||||
dir := t.TempDir()
|
||||
|
||||
textPath := filepath.Join(dir, "session.md")
|
||||
if err := app.SaveExportFile(textPath, "# 会话\n", false); err != nil {
|
||||
t.Fatalf("save text export: %v", err)
|
||||
}
|
||||
text, err := os.ReadFile(textPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read text export: %v", err)
|
||||
}
|
||||
if got, want := string(text), "# 会话\n"; got != want {
|
||||
t.Fatalf("text export = %q, want %q", got, want)
|
||||
}
|
||||
|
||||
binaryPath := filepath.Join(dir, "session.png")
|
||||
binary := []byte{0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n', 0x00, 0xff}
|
||||
if err := app.SaveExportFile(binaryPath, base64.StdEncoding.EncodeToString(binary), true); err != nil {
|
||||
t.Fatalf("save binary export: %v", err)
|
||||
}
|
||||
written, err := os.ReadFile(binaryPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read binary export: %v", err)
|
||||
}
|
||||
if string(written) != string(binary) {
|
||||
t.Fatalf("binary export = %v, want %v", written, binary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportFileRejectsInvalidBase64(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join(t.TempDir(), "broken.pdf")
|
||||
err := (&App{}).SaveExportFile(path, "not base64!", true)
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid base64 error")
|
||||
}
|
||||
if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid payload should not create a file, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportErrorsDoNotExposeSelectedDirectory(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
missingDir := filepath.Join(dir, "private-export-directory")
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("image"))
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
run func(string) error
|
||||
}{
|
||||
{
|
||||
name: "single file",
|
||||
path: filepath.Join(missingDir, "session.pdf"),
|
||||
run: func(path string) error {
|
||||
return (&App{}).SaveExportFile(path, payload, true)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multipart image",
|
||||
path: filepath.Join(missingDir, "session.png"),
|
||||
run: func(path string) error {
|
||||
return (&App{}).SaveExportImageFiles(path, []string{payload, payload})
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
err := test.run(test.path)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing export directory to fail")
|
||||
}
|
||||
if strings.Contains(err.Error(), dir) {
|
||||
t.Fatalf("export error exposed selected directory: %q", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "session") {
|
||||
t.Fatalf("export error should retain a safe file name: %q", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportImageFilesWritesNumberedParts(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.archive.png")
|
||||
payloads := [][]byte{{0x01, 0x02}, {0x03, 0x04}, {0x05, 0x06}}
|
||||
encoded := make([]string, len(payloads))
|
||||
for i, payload := range payloads {
|
||||
encoded[i] = base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
|
||||
if err := (&App{}).SaveExportImageFiles(path, encoded); err != nil {
|
||||
t.Fatalf("save image parts: %v", err)
|
||||
}
|
||||
for i, want := range payloads {
|
||||
partPath := filepath.Join(dir, fmt.Sprintf("session.archive-%d-of-3.png", i+1))
|
||||
got, err := os.ReadFile(partPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read image part %d: %v", i+1, err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Fatalf("image part %d = %v, want %v", i+1, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatalf("multi-part export should not write the selected base path, stat error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportImageFilesPreservesSelectedPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows normalizes trailing spaces in file names")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
selectedPath := filepath.Join(dir, "session.png ")
|
||||
neighborPath := filepath.Join(dir, "session.png")
|
||||
if err := os.WriteFile(neighborPath, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatalf("seed neighboring file: %v", err)
|
||||
}
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
|
||||
|
||||
if err := (&App{}).SaveExportImageFiles(selectedPath, []string{payload}); err != nil {
|
||||
t.Fatalf("save exact selected path: %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(selectedPath); err != nil || string(got) != "new image" {
|
||||
t.Fatalf("selected path data = %q, err = %v", got, err)
|
||||
}
|
||||
if got, err := os.ReadFile(neighborPath); err != nil || string(got) != "keep me" {
|
||||
t.Fatalf("neighboring file changed: data=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportImageFilesMatchesNormalExportPermissions(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
referencePath := filepath.Join(dir, "reference.png")
|
||||
if err := (&App{}).SaveExportFile(referencePath, base64.StdEncoding.EncodeToString([]byte("reference")), true); err != nil {
|
||||
t.Fatalf("save reference export: %v", err)
|
||||
}
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("image"))
|
||||
if err := (&App{}).SaveExportImageFiles(filepath.Join(dir, "session.png"), []string{payload, payload}); err != nil {
|
||||
t.Fatalf("save multipart export: %v", err)
|
||||
}
|
||||
|
||||
referenceInfo, err := os.Stat(referencePath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat reference export: %v", err)
|
||||
}
|
||||
partInfo, err := os.Stat(filepath.Join(dir, "session-1-of-2.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("stat multipart export: %v", err)
|
||||
}
|
||||
if got, want := partInfo.Mode().Perm(), referenceInfo.Mode().Perm(); got != want {
|
||||
t.Fatalf("multipart permissions = %v, want normal export permissions %v", got, want)
|
||||
}
|
||||
if matches, err := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); err != nil || len(matches) != 0 {
|
||||
t.Fatalf("staged files remain after successful export: matches=%v err=%v", matches, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportImageFilesRejectsCollisionWithoutPartialOutput(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.png")
|
||||
collisionPath := filepath.Join(dir, "session-2-of-3.png")
|
||||
if err := os.WriteFile(collisionPath, []byte("keep me"), 0o644); err != nil {
|
||||
t.Fatalf("seed collision: %v", err)
|
||||
}
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("new image"))
|
||||
|
||||
err := (&App{}).SaveExportImageFiles(path, []string{payload, payload, payload})
|
||||
if err == nil {
|
||||
t.Fatal("expected existing numbered export to reject the batch")
|
||||
}
|
||||
if got, readErr := os.ReadFile(collisionPath); readErr != nil || string(got) != "keep me" {
|
||||
t.Fatalf("existing image part changed: data=%q err=%v", got, readErr)
|
||||
}
|
||||
for _, name := range []string{"session-1-of-3.png", "session-3-of-3.png"} {
|
||||
if _, statErr := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("collision should leave no partial output %s, stat error = %v", name, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExportImageFilesDecodesAllPartsBeforeWriting(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.png")
|
||||
valid := base64.StdEncoding.EncodeToString([]byte("image"))
|
||||
|
||||
err := (&App{}).SaveExportImageFiles(path, []string{valid, "not base64!", valid})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid image payload to reject the batch")
|
||||
}
|
||||
for i := 1; i <= 3; i++ {
|
||||
partPath := filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i))
|
||||
if _, statErr := os.Stat(partPath); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("invalid payload should leave no image part %d, stat error = %v", i, statErr)
|
||||
}
|
||||
}
|
||||
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
|
||||
t.Fatalf("invalid payload left staged files: matches=%v err=%v", matches, globErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveExclusiveExportFilesRollsBackCommittedTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
target := filepath.Join(t.TempDir(), "duplicate.png")
|
||||
|
||||
err := saveExclusiveExportFiles(
|
||||
[]string{target, target},
|
||||
[][]byte{[]byte("first"), []byte("second")},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate exclusive target to fail")
|
||||
}
|
||||
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("failed batch should roll back its committed target, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRollbackDoesNotRemoveReplacedExportTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
tempPath := filepath.Join(dir, ".staged.png")
|
||||
targetPath := filepath.Join(dir, "session.png")
|
||||
if err := os.WriteFile(tempPath, []byte("staged"), 0o644); err != nil {
|
||||
t.Fatalf("write staged file: %v", err)
|
||||
}
|
||||
created, err := commitStagedExportFile(tempPath, targetPath)
|
||||
if err != nil {
|
||||
t.Fatalf("commit staged file: %v", err)
|
||||
}
|
||||
tempInfo, err := os.Lstat(tempPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat staged file: %v", err)
|
||||
}
|
||||
if !os.SameFile(created, tempInfo) {
|
||||
t.Fatal("commit must return the staged inode identity")
|
||||
}
|
||||
if err := os.Remove(targetPath); err != nil {
|
||||
t.Fatalf("replace committed target: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(targetPath, []byte("replacement"), 0o644); err != nil {
|
||||
t.Fatalf("write replacement target: %v", err)
|
||||
}
|
||||
|
||||
rollbackCommittedExportFiles([]committedExportFile{{path: targetPath, info: created}})
|
||||
if got, err := os.ReadFile(targetPath); err != nil || string(got) != "replacement" {
|
||||
t.Fatalf("rollback removed replacement: data=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentMultipartExportsHaveSingleCompleteWinner(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.png")
|
||||
encode := func(values ...string) []string {
|
||||
encoded := make([]string, len(values))
|
||||
for i, value := range values {
|
||||
encoded[i] = base64.StdEncoding.EncodeToString([]byte(value))
|
||||
}
|
||||
return encoded
|
||||
}
|
||||
batches := [][]string{
|
||||
encode("a-1", "a-2", "a-3"),
|
||||
encode("b-1", "b-2", "b-3"),
|
||||
}
|
||||
start := make(chan struct{})
|
||||
errs := make(chan error, len(batches))
|
||||
var ready sync.WaitGroup
|
||||
ready.Add(len(batches))
|
||||
for _, batch := range batches {
|
||||
go func() {
|
||||
ready.Done()
|
||||
<-start
|
||||
errs <- (&App{}).SaveExportImageFiles(path, batch)
|
||||
}()
|
||||
}
|
||||
ready.Wait()
|
||||
close(start)
|
||||
|
||||
successes := 0
|
||||
for range batches {
|
||||
if err := <-errs; err == nil {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("successful concurrent exports = %d, want exactly one", successes)
|
||||
}
|
||||
first, err := os.ReadFile(filepath.Join(dir, "session-1-of-3.png"))
|
||||
if err != nil {
|
||||
t.Fatalf("read winning first part: %v", err)
|
||||
}
|
||||
winner := string(first[:1])
|
||||
for i := 1; i <= 3; i++ {
|
||||
got, err := os.ReadFile(filepath.Join(dir, fmt.Sprintf("session-%d-of-3.png", i)))
|
||||
if err != nil {
|
||||
t.Fatalf("read winning part %d: %v", i, err)
|
||||
}
|
||||
if want := fmt.Sprintf("%s-%d", winner, i); string(got) != want {
|
||||
t.Fatalf("winning part %d = %q, want %q from one batch", i, got, want)
|
||||
}
|
||||
}
|
||||
if matches, globErr := filepath.Glob(filepath.Join(dir, ".reasonix-export-*")); globErr != nil || len(matches) != 0 {
|
||||
t.Fatalf("concurrent export left staged files: matches=%v err=%v", matches, globErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportFileFiltersSelectExpectedNativePattern(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
mime string
|
||||
ext string
|
||||
want string
|
||||
}{
|
||||
{mime: "application/pdf", ext: ".pdf", want: "*.pdf"},
|
||||
{mime: "image/png", ext: ".png", want: "*.png"},
|
||||
{mime: "application/octet-stream", ext: ".bin", want: "*.bin"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
filters := exportFileFilters(test.mime, test.ext)
|
||||
if len(filters) != 1 || filters[0].Pattern != test.want {
|
||||
t.Fatalf("filters for %s = %#v, want pattern %q", test.mime, filters, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/taskcatalog"
|
||||
)
|
||||
|
||||
func TestExtensionGenerationBumpsOnMCPMutationSites(t *testing.T) {
|
||||
// Guard mutation sites that publish shared Host / config changes while
|
||||
// controller builds may still be running off the lifecycle lock.
|
||||
entries, err := os.ReadDir(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var sources []string
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
raw, readErr := os.ReadFile(filepath.Join(".", name))
|
||||
if readErr != nil {
|
||||
t.Fatal(readErr)
|
||||
}
|
||||
sources = append(sources, string(raw))
|
||||
}
|
||||
src := strings.Join(sources, "\n")
|
||||
for _, name := range []string{
|
||||
"func (a *App) InstallMCPServer",
|
||||
"func (a *App) UpdateMCPServer",
|
||||
"func (a *App) RemoveMCPServer",
|
||||
"func (a *App) ReconnectMCPServer",
|
||||
"func (a *App) ClearMCPServerAuthentication",
|
||||
} {
|
||||
idx := strings.Index(src, name)
|
||||
if idx < 0 {
|
||||
t.Fatalf("missing %s", name)
|
||||
}
|
||||
rest := src[idx:]
|
||||
next := strings.Index(rest[len(name):], "\nfunc (a *App) ")
|
||||
body := rest
|
||||
if next >= 0 {
|
||||
body = rest[:len(name)+next]
|
||||
}
|
||||
if !strings.Contains(body, "a.bumpExtensionGeneration()") && !strings.Contains(body, "saveDesktopMCPServerAndBump") {
|
||||
t.Fatalf("%s does not bump extensionGeneration", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControllerPublicationRejectsMutationThatCompletesAfterBoot(t *testing.T) {
|
||||
app := NewApp()
|
||||
generation := app.currentExtensionGeneration()
|
||||
|
||||
// Model a build that has finished extension boot while an MCP mutation is
|
||||
// queued. The writer must finish and bump the generation before publication.
|
||||
app.extensionBuildMu.RLock()
|
||||
mutationLocked := make(chan struct{})
|
||||
releaseMutation := make(chan struct{})
|
||||
go func() {
|
||||
unlock := app.lockMCPMutation("test-publication-fence")
|
||||
close(mutationLocked)
|
||||
<-releaseMutation
|
||||
unlock()
|
||||
}()
|
||||
app.extensionBuildMu.RUnlock()
|
||||
<-mutationLocked
|
||||
|
||||
publication := make(chan bool, 1)
|
||||
go func() {
|
||||
unlock, ok := app.lockTabControllerPublication(generation)
|
||||
if ok {
|
||||
unlock()
|
||||
}
|
||||
publication <- ok
|
||||
}()
|
||||
close(releaseMutation)
|
||||
if <-publication {
|
||||
t.Fatal("controller published after an MCP mutation changed its extension generation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskActionProjectKeepsAllowlistedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
app := &App{
|
||||
ctx: context.Background(),
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"active": {ID: "active", Scope: "project", WorkspaceRoot: root},
|
||||
},
|
||||
activeTabID: "active",
|
||||
}
|
||||
key := taskcatalog.ProjectKey(root)
|
||||
project, err := app.taskActionProject(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if abs, err := filepath.Abs(root); err == nil {
|
||||
root = abs
|
||||
}
|
||||
if project.Root != root {
|
||||
t.Fatalf("root = %q, want allowlisted %q", project.Root, root)
|
||||
}
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/boot"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// extensionCatalogCtrl stubs only what the model discovery/switch paths read
|
||||
// from a tab controller; everything else falls through the embedded nil
|
||||
// SessionAPI and must not be called.
|
||||
type extensionCatalogCtrl struct {
|
||||
control.SessionAPI
|
||||
catalog []provider.Descriptor
|
||||
}
|
||||
|
||||
func (c *extensionCatalogCtrl) ProviderCatalog() []provider.Descriptor { return c.catalog }
|
||||
|
||||
// TestModelsForTabIncludesExtensionProviders: the desktop model switcher
|
||||
// merges the tab controller's extension catalog into the config-backed list —
|
||||
// plugin refs arrive fully namespaced, duplicates collapse, and the active
|
||||
// plugin ref is marked current.
|
||||
func TestModelsForTabIncludesExtensionProviders(t *testing.T) {
|
||||
reloadRuntimeFixture(t) // one configured provider "old" with model old-model
|
||||
|
||||
app := NewApp()
|
||||
tab := &WorkspaceTab{
|
||||
ID: "tab_a",
|
||||
Scope: "global",
|
||||
Ready: true,
|
||||
model: "plugin/demo/fake/x",
|
||||
Ctrl: &extensionCatalogCtrl{catalog: []provider.Descriptor{
|
||||
{Ref: "plugin/demo/fake/x", Model: "x"},
|
||||
{Ref: "plugin/demo/fake/y", Model: "y"},
|
||||
{Ref: "old/old-model"}, // claim-replaced duplicate must not repeat
|
||||
// ProviderCatalog is merged, so it can also contain unnamespaced
|
||||
// config-backed descriptors. They are not extension models and must
|
||||
// never leak into a synthetic PLUGIN group in the desktop picker.
|
||||
{Ref: "deepseek-responses", DisplayName: "deepseek-responses"},
|
||||
{Ref: "siliconflow-cn", DisplayName: "siliconflow-cn"},
|
||||
}},
|
||||
disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
app.tabs = map[string]*WorkspaceTab{tab.ID: tab}
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
|
||||
models := app.ModelsForTab(tab.ID)
|
||||
var pluginX, pluginY, oldCount *ModelInfo
|
||||
oldSeen := 0
|
||||
for i := range models {
|
||||
switch models[i].Ref {
|
||||
case "plugin/demo/fake/x":
|
||||
pluginX = &models[i]
|
||||
case "plugin/demo/fake/y":
|
||||
pluginY = &models[i]
|
||||
case "old/old-model":
|
||||
oldSeen++
|
||||
oldCount = &models[i]
|
||||
}
|
||||
}
|
||||
if pluginX == nil || pluginY == nil {
|
||||
t.Fatalf("ModelsForTab = %+v, want both plugin refs", models)
|
||||
}
|
||||
if pluginX.Provider != "plugin/demo" || pluginX.Model != "fake/x" {
|
||||
t.Fatalf("plugin ref display = %+v, want provider plugin/demo model fake/x", pluginX)
|
||||
}
|
||||
if !pluginX.Current {
|
||||
t.Fatalf("active plugin ref not marked current: %+v", pluginX)
|
||||
}
|
||||
if oldSeen != 1 || oldCount == nil {
|
||||
t.Fatalf("config ref repeated %d times, want exactly one", oldSeen)
|
||||
}
|
||||
for _, info := range models {
|
||||
if info.Ref == "deepseek-responses" || info.Ref == "siliconflow-cn" || info.Provider == "plugin" {
|
||||
t.Fatalf("unnamespaced provider descriptor leaked into plugin models: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// A tab whose controller has no extension catalog (nil → no sidecar
|
||||
// declared providers) enumerates config only, as before.
|
||||
tab.Ctrl = &extensionCatalogCtrl{}
|
||||
for _, info := range app.ModelsForTab(tab.ID) {
|
||||
if strings.HasPrefix(info.Ref, "plugin/") {
|
||||
t.Fatalf("empty catalog produced plugin ref %+v", info)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetModelForTabPluginRefReachesBoot: with a controller whose merged
|
||||
// catalog declares the plugin ref, SetModelForTab's config gate accepts the
|
||||
// ref and hands it to boot — which is the resolver of record (here it fails
|
||||
// only because no sidecar is installed in this test's REASONIX_HOME). A
|
||||
// non-plugin unknown ref must still fail at the desktop gate, before boot.
|
||||
func TestSetModelForTabPluginRefReachesBoot(t *testing.T) {
|
||||
dir := reloadRuntimeFixture(t)
|
||||
|
||||
oldExec := agent.New(nil, nil, agent.NewSession("old system prompt"), agent.Options{}, event.Discard)
|
||||
app := NewApp()
|
||||
app.ctx = context.Background()
|
||||
app.readyHook = func() {}
|
||||
oldCtrl := control.New(control.Options{
|
||||
Executor: oldExec,
|
||||
SessionDir: dir,
|
||||
Label: "old",
|
||||
Sink: event.Discard,
|
||||
// The merged catalog this controller would expose with the demo
|
||||
// sidecar live: plugin/demo/fake/x is a valid switch target.
|
||||
ProviderResolver: &provider.StaticResolver{Descriptors: []provider.Descriptor{
|
||||
{Ref: "plugin/demo/fake/x", Model: "x"},
|
||||
}},
|
||||
})
|
||||
reloadRuntimeTab(t, app, dir, oldCtrl)
|
||||
|
||||
err := app.SetModelForTab("tab_a", "plugin/demo/fake/x")
|
||||
if err == nil {
|
||||
t.Fatal("SetModelForTab succeeded without an installed sidecar; expected boot to gate the plugin ref")
|
||||
}
|
||||
if !errors.Is(err, boot.ErrUnknownModel) {
|
||||
t.Fatalf("SetModelForTab error = %v, want boot.ErrUnknownModel (the desktop gate must pass plugin refs through to boot)", err)
|
||||
}
|
||||
|
||||
// The desktop config gate still rejects unknown NON-plugin refs itself.
|
||||
err = app.SetModelForTab("tab_a", "ghost/never-existed")
|
||||
if err == nil || !strings.Contains(err.Error(), `unknown model "ghost/never-existed"`) {
|
||||
t.Fatalf("SetModelForTab non-plugin error = %v, want the desktop unknown-model gate", err)
|
||||
}
|
||||
if errors.Is(err, boot.ErrUnknownModel) {
|
||||
t.Fatalf("non-plugin unknown ref reached boot: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// ExtensionActionView is the JSON twin of control.ExtensionActionView for the
|
||||
// desktop frontend: one handshake-declared extension UI action. Description
|
||||
// carries the control view's Label — the protocol declares a single
|
||||
// human-readable label per action, so the frontend shows it as the action's
|
||||
// description line. Slash is the public invocation name, "/<plugin>:<action>".
|
||||
type ExtensionActionView struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Action string `json:"action"`
|
||||
Slash string `json:"slash"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// extensionActionsForCtrl maps the control port's action views to their JSON
|
||||
// twins. Nil controller → empty, so the palette simply shows no extension
|
||||
// group while a tab's runtime is still starting.
|
||||
func extensionActionsForCtrl(ctrl control.SessionAPI) []ExtensionActionView {
|
||||
out := []ExtensionActionView{}
|
||||
if ctrl == nil {
|
||||
return out
|
||||
}
|
||||
for _, action := range ctrl.ExtensionActions() {
|
||||
out = append(out, ExtensionActionView{
|
||||
Plugin: action.PluginID,
|
||||
Action: action.ActionID,
|
||||
Slash: action.Slash,
|
||||
Description: action.Label,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtensionActions enumerates the handshake-declared extension UI actions of
|
||||
// the runtime owning tabID (empty tabID = active tab). Unknown tabs and tabs
|
||||
// without a running runtime yield an empty list rather than an error so the
|
||||
// command palette can poll freely.
|
||||
func (a *App) ExtensionActions(tabID string) ([]ExtensionActionView, error) {
|
||||
return extensionActionsForCtrl(a.ctrlByTabID(tabID)), nil
|
||||
}
|
||||
|
||||
// extensionCtrlForTab resolves the driving controller for extension calls,
|
||||
// erroring instead of silently no-opping (unlike the read path above, invoke
|
||||
// and submit are user-initiated mutations that must surface misrouting).
|
||||
func (a *App) extensionCtrlForTab(tabID string) (control.SessionAPI, error) {
|
||||
if a.tabByID(tabID) == nil {
|
||||
return nil, fmt.Errorf("no tab %q", tabID)
|
||||
}
|
||||
ctrl := a.ctrlByTabID(tabID)
|
||||
if ctrl == nil {
|
||||
return nil, fmt.Errorf("tab %q has no running runtime", tabID)
|
||||
}
|
||||
return ctrl, nil
|
||||
}
|
||||
|
||||
// InvokeExtensionAction invokes one registered extension action by its public
|
||||
// "/<plugin>:<action>" name on behalf of tabID (empty = active tab) and
|
||||
// returns the extension's (already redacted) result message.
|
||||
func (a *App) InvokeExtensionAction(tabID, name string, args map[string]string) (string, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return "", fmt.Errorf("extension action name is required")
|
||||
}
|
||||
ctrl, err := a.extensionCtrlForTab(tabID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return ctrl.InvokeExtensionAction(a.bootContext(), name, args)
|
||||
}
|
||||
|
||||
// SubmitExtensionForm delivers one extension form surface's values back to the
|
||||
// owning sidecar. A dismissal rides the same path as values{"cancelled": true}
|
||||
// — the structured-UI protocol has no dedicated cancel verb on the submit
|
||||
// channel, so the frontend reports cancellation as an explicit value, distinct
|
||||
// from an empty value set.
|
||||
func (a *App) SubmitExtensionForm(tabID, pluginID, surfaceID string, values map[string]any) error {
|
||||
if strings.TrimSpace(pluginID) == "" || strings.TrimSpace(surfaceID) == "" {
|
||||
return fmt.Errorf("extension form submission requires plugin and surface ids")
|
||||
}
|
||||
ctrl, err := a.extensionCtrlForTab(tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctrl.SubmitExtensionForm(a.bootContext(), pluginID, surfaceID, values)
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// extensionUIController fakes the extension-UI slice of control.SessionAPI:
|
||||
// the Capabilities port methods plus the concrete controller's
|
||||
// SubmitExtensionForm (asserted locally by the binding until the port grows
|
||||
// it).
|
||||
type extensionUIController struct {
|
||||
control.SessionAPI
|
||||
actions []control.ExtensionActionView
|
||||
invokeMessage string
|
||||
invokeErr error
|
||||
submitErr error
|
||||
invokeName string
|
||||
invokeArgs map[string]string
|
||||
submitPluginID string
|
||||
submitSurfaceID string
|
||||
submitValues map[string]any
|
||||
}
|
||||
|
||||
func (c *extensionUIController) ExtensionActions() []control.ExtensionActionView {
|
||||
return append([]control.ExtensionActionView(nil), c.actions...)
|
||||
}
|
||||
|
||||
func (c *extensionUIController) InvokeExtensionAction(_ context.Context, name string, args map[string]string) (string, error) {
|
||||
c.invokeName = name
|
||||
c.invokeArgs = args
|
||||
return c.invokeMessage, c.invokeErr
|
||||
}
|
||||
|
||||
func (c *extensionUIController) SubmitExtensionForm(_ context.Context, pluginID, surfaceID string, values map[string]any) error {
|
||||
c.submitPluginID = pluginID
|
||||
c.submitSurfaceID = surfaceID
|
||||
c.submitValues = values
|
||||
return c.submitErr
|
||||
}
|
||||
|
||||
func newExtensionTestApp(ctrl control.SessionAPI) *App {
|
||||
return &App{
|
||||
tabs: map[string]*WorkspaceTab{
|
||||
"target": {ID: "target", Scope: "global", Ready: true, Ctrl: ctrl, disabledMCP: map[string]ServerView{}},
|
||||
"focused": {ID: "focused", Scope: "global", Ready: true, disabledMCP: map[string]ServerView{}},
|
||||
},
|
||||
tabOrder: []string{"target", "focused"},
|
||||
activeTabID: "focused",
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtensionActionsMapsControlViewsAndRoutesByTab(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
targetCtrl := &extensionUIController{actions: []control.ExtensionActionView{
|
||||
{PluginID: "alpha", ActionID: "act1", Label: "Run the thing", Slash: "/alpha:act1"},
|
||||
{PluginID: "beta", ActionID: "act2", Label: "", Slash: "/beta:act2"},
|
||||
}}
|
||||
app := newExtensionTestApp(targetCtrl)
|
||||
|
||||
views, err := app.ExtensionActions("target")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtensionActions: %v", err)
|
||||
}
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("ExtensionActions = %+v, want 2 views", views)
|
||||
}
|
||||
if views[0] != (ExtensionActionView{Plugin: "alpha", Action: "act1", Slash: "/alpha:act1", Description: "Run the thing"}) {
|
||||
t.Fatalf("view[0] = %+v, want JSON twin of the control view", views[0])
|
||||
}
|
||||
if views[1].Description != "" {
|
||||
t.Fatalf("empty label must stay an omitted description, got %+v", views[1])
|
||||
}
|
||||
|
||||
// Empty tab id resolves the active tab.
|
||||
active, err := app.ExtensionActions("")
|
||||
if err != nil {
|
||||
t.Fatalf("ExtensionActions(active): %v", err)
|
||||
}
|
||||
if len(active) != 0 {
|
||||
t.Fatalf("active tab actions = %+v, want empty (focused tab has none)", active)
|
||||
}
|
||||
|
||||
// Unknown tabs and tabs without a running runtime yield an empty list.
|
||||
for _, tabID := range []string{"missing", "focused-noruntime"} {
|
||||
app.tabs["focused-noruntime"] = &WorkspaceTab{ID: "focused-noruntime", Scope: "global", disabledMCP: map[string]ServerView{}}
|
||||
got, err := app.ExtensionActions(tabID)
|
||||
if err != nil {
|
||||
t.Fatalf("ExtensionActions(%q): %v", tabID, err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("ExtensionActions(%q) = %+v, want empty", tabID, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeExtensionActionForwardsNameAndArgs(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
targetCtrl := &extensionUIController{invokeMessage: "done"}
|
||||
focusedCtrl := &extensionUIController{}
|
||||
app := newExtensionTestApp(targetCtrl)
|
||||
|
||||
message, err := app.InvokeExtensionAction("target", "/alpha:act1", map[string]string{"k": "v"})
|
||||
if err != nil {
|
||||
t.Fatalf("InvokeExtensionAction: %v", err)
|
||||
}
|
||||
if message != "done" {
|
||||
t.Fatalf("message = %q, want done", message)
|
||||
}
|
||||
if targetCtrl.invokeName != "/alpha:act1" || targetCtrl.invokeArgs["k"] != "v" {
|
||||
t.Fatalf("controller saw name=%q args=%+v", targetCtrl.invokeName, targetCtrl.invokeArgs)
|
||||
}
|
||||
if focusedCtrl.invokeName != "" {
|
||||
t.Fatalf("focused tab received scoped invoke: %q", focusedCtrl.invokeName)
|
||||
}
|
||||
|
||||
targetCtrl.invokeErr = errors.New("sidecar said no")
|
||||
if _, err := app.InvokeExtensionAction("target", "/alpha:act1", nil); err == nil || err.Error() != "sidecar said no" {
|
||||
t.Fatalf("controller error must pass through, got %v", err)
|
||||
}
|
||||
|
||||
if _, err := app.InvokeExtensionAction("target", " ", nil); err == nil {
|
||||
t.Fatal("blank action name must fail before reaching the controller")
|
||||
}
|
||||
if _, err := app.InvokeExtensionAction("missing", "/alpha:act1", nil); err == nil {
|
||||
t.Fatal("unknown tab must fail")
|
||||
}
|
||||
if _, err := app.InvokeExtensionAction("focused-noruntime", "/alpha:act1", nil); err == nil {
|
||||
t.Fatal("tab without a running runtime must fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmitExtensionFormRoutesValues(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
targetCtrl := &extensionUIController{}
|
||||
app := newExtensionTestApp(targetCtrl)
|
||||
|
||||
values := map[string]any{"name": "x", "flags": []any{"a", "b"}, "agree": true}
|
||||
if err := app.SubmitExtensionForm("target", "alpha", "f1", values); err != nil {
|
||||
t.Fatalf("SubmitExtensionForm: %v", err)
|
||||
}
|
||||
if targetCtrl.submitPluginID != "alpha" || targetCtrl.submitSurfaceID != "f1" {
|
||||
t.Fatalf("controller saw %q/%q", targetCtrl.submitPluginID, targetCtrl.submitSurfaceID)
|
||||
}
|
||||
if targetCtrl.submitValues["name"] != "x" || targetCtrl.submitValues["agree"] != true {
|
||||
t.Fatalf("values = %+v", targetCtrl.submitValues)
|
||||
}
|
||||
|
||||
// A dismissal rides the same path as an explicit cancelled value.
|
||||
if err := app.SubmitExtensionForm("target", "alpha", "f1", map[string]any{"cancelled": true}); err != nil {
|
||||
t.Fatalf("SubmitExtensionForm(cancel): %v", err)
|
||||
}
|
||||
if targetCtrl.submitValues["cancelled"] != true {
|
||||
t.Fatalf("cancelled value = %+v", targetCtrl.submitValues)
|
||||
}
|
||||
|
||||
targetCtrl.submitErr = errors.New("not accepted")
|
||||
if err := app.SubmitExtensionForm("target", "alpha", "f1", nil); err == nil || err.Error() != "not accepted" {
|
||||
t.Fatalf("controller error must pass through, got %v", err)
|
||||
}
|
||||
|
||||
if err := app.SubmitExtensionForm("target", "", "f1", nil); err == nil {
|
||||
t.Fatal("blank plugin id must fail before reaching the controller")
|
||||
}
|
||||
if err := app.SubmitExtensionForm("target", "alpha", "", nil); err == nil {
|
||||
t.Fatal("blank surface id must fail before reaching the controller")
|
||||
}
|
||||
if err := app.SubmitExtensionForm("missing", "alpha", "f1", nil); err == nil {
|
||||
t.Fatal("unknown tab must fail")
|
||||
}
|
||||
}
|
||||
@@ -1,425 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
externalOpenerFileManager = "file-manager"
|
||||
externalOpenerEditor = "editor"
|
||||
externalOpenerTerminal = "terminal"
|
||||
externalOpenerCatalogTTL = 15 * time.Second
|
||||
)
|
||||
|
||||
// ExternalOpenerView is the renderer-safe description of one installed app.
|
||||
// Target executable paths and launch arguments stay in the native shell so the
|
||||
// frontend can never turn this feature into an arbitrary command runner.
|
||||
type ExternalOpenerView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
IconDataURL string `json:"iconDataUrl,omitempty"`
|
||||
}
|
||||
|
||||
// ExternalOpenersView is the complete state for the Codex-style Open control.
|
||||
type ExternalOpenersView struct {
|
||||
Openers []ExternalOpenerView `json:"openers"`
|
||||
Preferred string `json:"preferred"`
|
||||
WorkspaceOpenable bool `json:"workspaceOpenable,omitempty"`
|
||||
}
|
||||
|
||||
type externalOpenerSpec struct {
|
||||
View ExternalOpenerView
|
||||
Target string
|
||||
LaunchMode string
|
||||
IconSource string
|
||||
}
|
||||
|
||||
type externalOpenerCatalogCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
discover func() []externalOpenerSpec
|
||||
now func() time.Time
|
||||
loaded bool
|
||||
loadedAt time.Time
|
||||
specs []externalOpenerSpec
|
||||
refreshDone chan struct{}
|
||||
}
|
||||
|
||||
var platformExternalOpenerCatalog = newExternalOpenerCatalogCache(externalOpenerCatalogTTL, platformExternalOpenerSpecs)
|
||||
|
||||
func newExternalOpenerCatalogCache(ttl time.Duration, discover func() []externalOpenerSpec) *externalOpenerCatalogCache {
|
||||
return &externalOpenerCatalogCache{ttl: ttl, discover: discover, now: time.Now}
|
||||
}
|
||||
|
||||
func cloneExternalOpenerSpecs(specs []externalOpenerSpec) []externalOpenerSpec {
|
||||
return append([]externalOpenerSpec(nil), specs...)
|
||||
}
|
||||
|
||||
func (c *externalOpenerCatalogCache) get() []externalOpenerSpec {
|
||||
for {
|
||||
c.mu.Lock()
|
||||
now := c.now()
|
||||
if c.loaded && now.Sub(c.loadedAt) < c.ttl {
|
||||
specs := cloneExternalOpenerSpecs(c.specs)
|
||||
c.mu.Unlock()
|
||||
return specs
|
||||
}
|
||||
if done := c.refreshDone; done != nil {
|
||||
c.mu.Unlock()
|
||||
<-done
|
||||
continue
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
c.refreshDone = done
|
||||
discover := c.discover
|
||||
c.mu.Unlock()
|
||||
|
||||
specs := discover()
|
||||
|
||||
c.mu.Lock()
|
||||
c.specs = cloneExternalOpenerSpecs(specs)
|
||||
c.loaded = true
|
||||
c.loadedAt = c.now()
|
||||
c.refreshDone = nil
|
||||
close(done)
|
||||
result := cloneExternalOpenerSpecs(c.specs)
|
||||
c.mu.Unlock()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func cachedPlatformExternalOpenerSpecs() []externalOpenerSpec {
|
||||
return platformExternalOpenerCatalog.get()
|
||||
}
|
||||
|
||||
// startDetachedExternalOpener reaps the launched process in the background so
|
||||
// exited children do not pile up as zombies for the desktop app's lifetime.
|
||||
func startDetachedExternalOpener(cmd *exec.Cmd) error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
go func() { _ = cmd.Wait() }()
|
||||
return nil
|
||||
}
|
||||
|
||||
func externalOpenerByID(specs []externalOpenerSpec, id string) (externalOpenerSpec, bool) {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
for _, spec := range specs {
|
||||
if spec.View.ID == id {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
return externalOpenerSpec{}, false
|
||||
}
|
||||
|
||||
func resolveExternalOpener(specs []externalOpenerSpec, preferred string) (externalOpenerSpec, bool) {
|
||||
if spec, ok := externalOpenerByID(specs, preferred); ok {
|
||||
return spec, true
|
||||
}
|
||||
for _, spec := range specs {
|
||||
if spec.View.Kind == externalOpenerFileManager {
|
||||
return spec, true
|
||||
}
|
||||
}
|
||||
if len(specs) == 0 {
|
||||
return externalOpenerSpec{}, false
|
||||
}
|
||||
return specs[0], true
|
||||
}
|
||||
|
||||
func externalOpenerViews(specs []externalOpenerSpec) []ExternalOpenerView {
|
||||
views := make([]ExternalOpenerView, 0, len(specs))
|
||||
seen := make(map[string]bool, len(specs))
|
||||
for _, spec := range specs {
|
||||
id := strings.ToLower(strings.TrimSpace(spec.View.ID))
|
||||
if id == "" || seen[id] || strings.TrimSpace(spec.View.Name) == "" {
|
||||
continue
|
||||
}
|
||||
spec.View.ID = id
|
||||
views = append(views, spec.View)
|
||||
seen[id] = true
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
func externalOpenerViewsWithIcons(specs []externalOpenerSpec) []ExternalOpenerView {
|
||||
withIcons := make([]externalOpenerSpec, len(specs))
|
||||
copy(withIcons, specs)
|
||||
for i := range withIcons {
|
||||
withIcons[i].View.IconDataURL = externalOpenerIconDataURL(withIcons[i])
|
||||
}
|
||||
return externalOpenerViews(withIcons)
|
||||
}
|
||||
|
||||
func (a *App) preferredExternalOpenerID() string {
|
||||
cfg, _, err := a.loadDesktopUserConfigForView()
|
||||
if err != nil || cfg == nil {
|
||||
return ""
|
||||
}
|
||||
return cfg.DesktopExternalOpener()
|
||||
}
|
||||
|
||||
// ExternalOpeners returns only applications detected on the current machine.
|
||||
// If a preference was copied from another OS or the app was uninstalled, the
|
||||
// returned preferred id falls back without rewriting the user's config.
|
||||
func (a *App) ExternalOpeners() ExternalOpenersView {
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
views := externalOpenerViewsWithIcons(specs)
|
||||
selected, ok := resolveExternalOpener(specs, a.preferredExternalOpenerID())
|
||||
if !ok {
|
||||
return ExternalOpenersView{Openers: views}
|
||||
}
|
||||
return ExternalOpenersView{Openers: views, Preferred: selected.View.ID}
|
||||
}
|
||||
|
||||
// ExternalOpenersForTab adds the tab-scoped local-workspace capability used by
|
||||
// the chat-header Open control. Scope is intentionally not part of the check:
|
||||
// both project tabs and Global tabs can own a real local workspace directory.
|
||||
func (a *App) ExternalOpenersForTab(tabID string) ExternalOpenersView {
|
||||
if _, err := a.externalOpenerWorkspacePathForTab(tabID); err != nil {
|
||||
return ExternalOpenersView{Openers: []ExternalOpenerView{}}
|
||||
}
|
||||
view := a.ExternalOpeners()
|
||||
view.WorkspaceOpenable = true
|
||||
return view
|
||||
}
|
||||
|
||||
// SetPreferredExternalOpener persists an installed, platform-owned opener id.
|
||||
func (a *App) SetPreferredExternalOpener(id string) error {
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
spec, ok := externalOpenerByID(specs, id)
|
||||
if !ok {
|
||||
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
|
||||
}
|
||||
return a.applyConfigOnly(func(c *config.Config) error {
|
||||
return c.SetDesktopExternalOpener(spec.View.ID)
|
||||
})
|
||||
}
|
||||
|
||||
// OpenWorkspaceInExternalOpener opens the active workspace using either the
|
||||
// requested installed app or the persisted/fallback selection when id is empty.
|
||||
func (a *App) OpenWorkspaceInExternalOpener(id string) error {
|
||||
return a.OpenWorkspaceInExternalOpenerForTab("", id)
|
||||
}
|
||||
|
||||
// OpenWorkspaceInExternalOpenerForTab is tab-scoped so a rapid tab switch cannot
|
||||
// send the wrong project to an external application.
|
||||
func (a *App) OpenWorkspaceInExternalOpenerForTab(tabID, id string) error {
|
||||
path, err := a.externalOpenerWorkspacePathForTab(tabID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
var spec externalOpenerSpec
|
||||
var ok bool
|
||||
if strings.TrimSpace(id) == "" {
|
||||
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
|
||||
} else {
|
||||
spec, ok = externalOpenerByID(specs, id)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
|
||||
}
|
||||
return launchPlatformExternalOpener(spec, path, true)
|
||||
}
|
||||
|
||||
func (a *App) externalOpenerWorkspacePathForTab(tabID string) (string, error) {
|
||||
root, _, ok := a.workspaceTargetForTab(tabID)
|
||||
if !ok {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
// A bound tab with no root has no stable workspace to expose. Keep the legacy
|
||||
// no-tab current-directory fallback, but do not turn an incomplete tab into
|
||||
// an opener for the Desktop process's launch directory.
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return "", os.ErrNotExist
|
||||
}
|
||||
path, err := workspaceBaseFromRoot(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return "", fmt.Errorf("workspace is not a directory")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// OpenLocalPathInExternalOpener opens an absolute local path with one of the
|
||||
// detected, platform-owned applications. It shares OpenLocalPath's safety
|
||||
// checks so a markdown link cannot turn an AI-generated executable path into
|
||||
// an executable launch.
|
||||
func (a *App) OpenLocalPathInExternalOpener(path, id string) error {
|
||||
path, err := normalizeLocalOpenPath(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !openTargetPathAllowed(path, info) {
|
||||
return fmt.Errorf("refusing to open executable target %q", path)
|
||||
}
|
||||
|
||||
specs := cachedPlatformExternalOpenerSpecs()
|
||||
var spec externalOpenerSpec
|
||||
var ok bool
|
||||
if strings.TrimSpace(id) == "" {
|
||||
spec, ok = resolveExternalOpener(specs, a.preferredExternalOpenerID())
|
||||
} else {
|
||||
spec, ok = externalOpenerByID(specs, id)
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("external opener %q is not available", strings.TrimSpace(id))
|
||||
}
|
||||
return launchPlatformExternalOpener(spec, path, info.IsDir())
|
||||
}
|
||||
|
||||
// SaveLocalPathAs copies a local file to a user-selected destination without
|
||||
// changing the source. Directories are intentionally excluded: Finder's
|
||||
// reveal action is the appropriate operation for them.
|
||||
func (a *App) SaveLocalPathAs(path string) (string, error) {
|
||||
path, err := normalizeLocalOpenPath(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return "", fmt.Errorf("cannot save a directory as a file")
|
||||
}
|
||||
if a.ctx == nil {
|
||||
return "", nil
|
||||
}
|
||||
target, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
|
||||
Title: "Save file as",
|
||||
DefaultDirectory: filepath.Dir(path),
|
||||
DefaultFilename: filepath.Base(path),
|
||||
CanCreateDirectories: true,
|
||||
})
|
||||
if err != nil || target == "" {
|
||||
return "", err
|
||||
}
|
||||
if filepath.Clean(target) == filepath.Clean(path) {
|
||||
return "", fmt.Errorf("destination is the same as the source")
|
||||
}
|
||||
if err := copyLocalPathAs(path, target); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func copyLocalPathAs(path, target string) (err error) {
|
||||
src, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
sourceInfo, err := src.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sourceInfo.IsDir() {
|
||||
return fmt.Errorf("cannot save a directory as a file")
|
||||
}
|
||||
sameFile, err := localSaveDestinationIsSource(sourceInfo, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if sameFile {
|
||||
return fmt.Errorf("destination is the same as the source")
|
||||
}
|
||||
|
||||
tmp, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".reasonix-copy-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer func() {
|
||||
if tmpName != "" {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
if _, err = io.Copy(tmp, src); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Sync(); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Chmod(sourceInfo.Mode().Perm()); err != nil {
|
||||
_ = tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err = tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = replaceLocalSaveDestination(tmpName, target); err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// localSaveDestinationIsSource compares filesystem identity, not just path
|
||||
// spelling. os.Stat follows aliases, so this catches case-insensitive paths,
|
||||
// symlinks, and hard links before the destination is replaced.
|
||||
func localSaveDestinationIsSource(sourceInfo os.FileInfo, target string) (bool, error) {
|
||||
targetInfo, err := os.Stat(target)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return os.SameFile(sourceInfo, targetInfo), nil
|
||||
}
|
||||
|
||||
// externalOpenerWorkingDirectory returns a valid directory for process launch.
|
||||
// Editors still receive the original file path as an argument; only the
|
||||
// process CWD changes when the requested path is a regular file.
|
||||
func externalOpenerWorkingDirectory(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && !info.IsDir() {
|
||||
return filepath.Dir(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func externalOpenerLaunchPath(spec externalOpenerSpec, path string) string {
|
||||
if spec.View.Kind == externalOpenerTerminal || isTerminalLaunchMode(spec.LaunchMode) {
|
||||
return externalOpenerWorkingDirectory(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func isTerminalLaunchMode(mode string) bool {
|
||||
switch mode {
|
||||
case "ghostty", "gnome-terminal", "konsole", "kitty", "alacritty", "cwd", "windows-terminal", "console":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func darwinInstalledApplicationIndex() map[string]string {
|
||||
home, _ := os.UserHomeDir()
|
||||
roots := []string{
|
||||
"/Applications",
|
||||
"/System/Applications",
|
||||
"/System/Applications/Utilities",
|
||||
"/System/Library/CoreServices",
|
||||
}
|
||||
if home != "" {
|
||||
roots = append(roots, filepath.Join(home, "Applications"))
|
||||
}
|
||||
index := make(map[string]string)
|
||||
add := func(path string) {
|
||||
path = strings.TrimSpace(path)
|
||||
if info, err := os.Stat(path); err == nil && info.IsDir() && strings.HasSuffix(strings.ToLower(path), ".app") {
|
||||
name := strings.ToLower(strings.TrimSuffix(filepath.Base(path), filepath.Ext(path)))
|
||||
if _, exists := index[name]; !exists {
|
||||
index[name] = path
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, root := range roots {
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasSuffix(strings.ToLower(entry.Name()), ".app") {
|
||||
add(filepath.Join(root, entry.Name()))
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "/usr/bin/mdfind", "kMDItemContentType == 'com.apple.application-bundle'").Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(string(out), "\n") {
|
||||
add(line)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func darwinApplicationPath(index map[string]string, names ...string) string {
|
||||
for _, name := range names {
|
||||
if path := index[strings.ToLower(strings.TrimSpace(name))]; path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func platformExternalOpenerSpecs() []externalOpenerSpec {
|
||||
installed := darwinInstalledApplicationIndex()
|
||||
var specs []externalOpenerSpec
|
||||
addMode := func(id, name, kind, mode, _ string, appNames ...string) {
|
||||
if path := darwinApplicationPath(installed, appNames...); path != "" {
|
||||
specs = append(specs, externalOpenerSpec{
|
||||
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
|
||||
Target: path,
|
||||
LaunchMode: mode,
|
||||
IconSource: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
add := func(id, name, kind, bundleID string, appNames ...string) {
|
||||
addMode(id, name, kind, "application", bundleID, appNames...)
|
||||
}
|
||||
|
||||
// Keep the order stable and close to the native Codex menu: common editors,
|
||||
// the platform browser, terminals, then the broader IDE catalog.
|
||||
add("vscode", "VS Code", externalOpenerEditor, "com.microsoft.VSCode", "Visual Studio Code")
|
||||
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "com.microsoft.VSCodeInsiders", "Visual Studio Code - Insiders")
|
||||
add("cursor", "Cursor", externalOpenerEditor, "com.todesktop.230313mzl4w4u92", "Cursor")
|
||||
add("finder", "Finder", externalOpenerFileManager, "com.apple.finder", "Finder")
|
||||
add("terminal", "Terminal", externalOpenerTerminal, "com.apple.Terminal", "Terminal")
|
||||
add("iterm", "iTerm2", externalOpenerTerminal, "com.googlecode.iterm2", "iTerm", "iTerm2")
|
||||
addMode("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", "com.mitchellh.ghostty", "Ghostty")
|
||||
add("xcode", "Xcode", externalOpenerEditor, "com.apple.dt.Xcode", "Xcode")
|
||||
add("android-studio", "Android Studio", externalOpenerEditor, "com.google.android.studio", "Android Studio")
|
||||
add("goland", "GoLand", externalOpenerEditor, "com.jetbrains.goland", "GoLand")
|
||||
add("pycharm", "PyCharm", externalOpenerEditor, "com.jetbrains.pycharm", "PyCharm", "PyCharm CE")
|
||||
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "com.jetbrains.intellij", "IntelliJ IDEA", "IntelliJ IDEA Ultimate", "IntelliJ IDEA CE")
|
||||
add("webstorm", "WebStorm", externalOpenerEditor, "com.jetbrains.WebStorm", "WebStorm")
|
||||
add("datagrip", "DataGrip", externalOpenerEditor, "com.jetbrains.datagrip", "DataGrip")
|
||||
add("codebuddy", "CodeBuddy", externalOpenerEditor, "com.tencent.codebuddy", "CodeBuddy")
|
||||
add("windsurf", "Windsurf", externalOpenerEditor, "com.exafunction.windsurf", "Windsurf")
|
||||
add("zed", "Zed", externalOpenerEditor, "dev.zed.Zed", "Zed")
|
||||
add("sublime-text", "Sublime Text", externalOpenerEditor, "com.sublimetext.4", "Sublime Text")
|
||||
add("kiro", "Kiro", externalOpenerEditor, "dev.kiro.desktop", "Kiro")
|
||||
return specs
|
||||
}
|
||||
|
||||
func darwinBundleIconPath(appPath string) string {
|
||||
resources := filepath.Join(appPath, "Contents", "Resources")
|
||||
infoPlist := filepath.Join(appPath, "Contents", "Info.plist")
|
||||
for _, key := range []string{"CFBundleIconFile", "CFBundleIconName"} {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
out, err := exec.CommandContext(ctx, "/usr/libexec/PlistBuddy", "-c", "Print :"+key, infoPlist).Output()
|
||||
cancel()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSpace(string(out))
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if filepath.Ext(name) == "" {
|
||||
name += ".icns"
|
||||
}
|
||||
path := filepath.Join(resources, name)
|
||||
if info, statErr := os.Stat(path); statErr == nil && !info.IsDir() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
// Older and some Electron bundles omit the icon key. The application icon
|
||||
// is normally the largest ICNS resource; document-type icons are much smaller.
|
||||
matches, _ := filepath.Glob(filepath.Join(resources, "*.icns"))
|
||||
var best string
|
||||
var bestSize int64
|
||||
for _, candidate := range matches {
|
||||
if info, err := os.Stat(candidate); err == nil && !info.IsDir() && info.Size() > bestSize {
|
||||
best = candidate
|
||||
bestSize = info.Size()
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
|
||||
iconPath := darwinBundleIconPath(spec.IconSource)
|
||||
if iconPath == "" {
|
||||
return ""
|
||||
}
|
||||
tempDir, err := os.MkdirTemp("", "reasonix-opener-icon-*")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
defer os.RemoveAll(tempDir)
|
||||
outPath := filepath.Join(tempDir, "icon.png")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "/usr/bin/sips", "-s", "format", "png", "-z", "64", "64", iconPath, "--out", outPath)
|
||||
cmd.Stdout = io.Discard
|
||||
cmd.Stderr = io.Discard
|
||||
if err := cmd.Run(); err != nil {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return externalOpenerPNGDataURL(data)
|
||||
}
|
||||
|
||||
func darwinExternalOpenerCommand(spec externalOpenerSpec, path string) *exec.Cmd {
|
||||
launchPath := externalOpenerLaunchPath(spec, path)
|
||||
if spec.LaunchMode == "ghostty" {
|
||||
return exec.Command("/usr/bin/open", "-na", spec.Target, "--args", "--working-directory="+launchPath)
|
||||
}
|
||||
return exec.Command("/usr/bin/open", "-a", spec.Target, launchPath)
|
||||
}
|
||||
|
||||
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, _ bool) error {
|
||||
return startDetachedExternalOpener(darwinExternalOpenerCommand(spec, path))
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDarwinExternalOpenersUseNativeApplicationMetadata(t *testing.T) {
|
||||
specs := platformExternalOpenerSpecs()
|
||||
finder, ok := externalOpenerByID(specs, "finder")
|
||||
if !ok {
|
||||
t.Fatal("Finder must always be present on macOS")
|
||||
}
|
||||
if !strings.HasSuffix(finder.IconSource, "Finder.app") {
|
||||
t.Fatalf("Finder icon source = %q, want native application bundle", finder.IconSource)
|
||||
}
|
||||
if icon := externalOpenerIconDataURL(finder); !strings.HasPrefix(icon, "data:image/png;base64,") {
|
||||
t.Fatalf("Finder native icon = %q, want PNG data URL", icon)
|
||||
}
|
||||
if iterm, installed := externalOpenerByID(specs, "iterm"); installed && iterm.View.Name != "iTerm2" {
|
||||
t.Fatalf("iTerm label = %q, want official iTerm2 name", iterm.View.Name)
|
||||
}
|
||||
if ghostty, installed := externalOpenerByID(specs, "ghostty"); installed && ghostty.LaunchMode != "ghostty" {
|
||||
t.Fatalf("Ghostty launch mode = %q, want working-directory aware mode", ghostty.LaunchMode)
|
||||
} else if installed {
|
||||
path := "/tmp/reasonix workspace"
|
||||
want := []string{"/usr/bin/open", "-na", ghostty.Target, "--args", "--working-directory=" + path}
|
||||
if got := darwinExternalOpenerCommand(ghostty, path).Args; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("Ghostty command = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinCatalogIncludesInstalledCodexApplications(t *testing.T) {
|
||||
installed := darwinInstalledApplicationIndex()
|
||||
specs := platformExternalOpenerSpecs()
|
||||
for appName, id := range map[string]string{
|
||||
"xcode": "xcode",
|
||||
"android studio": "android-studio",
|
||||
"pycharm": "pycharm",
|
||||
} {
|
||||
if installed[appName] == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := externalOpenerByID(specs, id); !ok {
|
||||
t.Errorf("installed %s is missing from external opener catalog", appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarwinInstalledOpenersExposeNativeIcons(t *testing.T) {
|
||||
for _, spec := range platformExternalOpenerSpecs() {
|
||||
if spec.IconSource == "" {
|
||||
t.Errorf("%s has no native icon source", spec.View.Name)
|
||||
continue
|
||||
}
|
||||
if icon := externalOpenerIconDataURL(spec); !strings.HasPrefix(icon, "data:image/png;base64,") {
|
||||
t.Errorf("%s native icon could not be extracted", spec.View.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const maxExternalOpenerIconBytes = 768 << 10
|
||||
|
||||
var externalOpenerIconCache sync.Map
|
||||
|
||||
func externalOpenerIconDataURL(spec externalOpenerSpec) string {
|
||||
source := strings.TrimSpace(spec.IconSource)
|
||||
if source == "" {
|
||||
return ""
|
||||
}
|
||||
cacheKey := source
|
||||
if info, err := os.Stat(source); err == nil {
|
||||
cacheKey = fmt.Sprintf("%s:%d:%d", source, info.ModTime().UnixNano(), info.Size())
|
||||
}
|
||||
if cached, ok := externalOpenerIconCache.Load(cacheKey); ok {
|
||||
return cached.(string)
|
||||
}
|
||||
icon := platformExternalOpenerIconDataURL(spec)
|
||||
if icon != "" {
|
||||
externalOpenerIconCache.Store(cacheKey, icon)
|
||||
}
|
||||
return icon
|
||||
}
|
||||
|
||||
func externalOpenerIconFileDataURL(path string) string {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() || info.Size() <= 0 || info.Size() > maxExternalOpenerIconBytes {
|
||||
return ""
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil || len(data) == 0 || len(data) > maxExternalOpenerIconBytes {
|
||||
return ""
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
switch ext {
|
||||
case ".png":
|
||||
return externalOpenerPNGDataURL(data)
|
||||
case ".jpg", ".jpeg":
|
||||
return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(data)
|
||||
case ".webp":
|
||||
return "data:image/webp;base64," + base64.StdEncoding.EncodeToString(data)
|
||||
case ".svg":
|
||||
return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString(data)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func externalOpenerPNGDataURL(data []byte) string {
|
||||
if len(data) == 0 || len(data) > maxExternalOpenerIconBytes {
|
||||
return ""
|
||||
}
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(data)
|
||||
}
|
||||
|
||||
func externalOpenerPNGFromBGRAComposites(black, white []byte, width, height int) []byte {
|
||||
if width <= 0 || height <= 0 || len(black) != width*height*4 || len(white) != len(black) {
|
||||
return nil
|
||||
}
|
||||
clampByte := func(value int) uint8 {
|
||||
if value < 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 255 {
|
||||
return 255
|
||||
}
|
||||
return uint8(value)
|
||||
}
|
||||
imageData := image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
for source := 0; source < len(black); source += 4 {
|
||||
blackB, blackG, blackR := int(black[source]), int(black[source+1]), int(black[source+2])
|
||||
whiteB, whiteG, whiteR := int(white[source]), int(white[source+1]), int(white[source+2])
|
||||
diff := func(light, dark int) int {
|
||||
if light <= dark {
|
||||
return 0
|
||||
}
|
||||
return int(clampByte(light - dark))
|
||||
}
|
||||
alpha := 255 - (diff(whiteR, blackR)+diff(whiteG, blackG)+diff(whiteB, blackB))/3
|
||||
destination := source
|
||||
if alpha > 0 {
|
||||
imageData.Pix[destination] = clampByte(blackR * 255 / alpha)
|
||||
imageData.Pix[destination+1] = clampByte(blackG * 255 / alpha)
|
||||
imageData.Pix[destination+2] = clampByte(blackB * 255 / alpha)
|
||||
}
|
||||
imageData.Pix[destination+3] = uint8(alpha)
|
||||
}
|
||||
var encoded bytes.Buffer
|
||||
if err := png.Encode(&encoded, imageData); err != nil {
|
||||
return nil
|
||||
}
|
||||
return encoded.Bytes()
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type linuxDesktopEntry struct {
|
||||
Path string
|
||||
ID string
|
||||
Name string
|
||||
Icon string
|
||||
}
|
||||
|
||||
func linuxExecutable(names ...string) string {
|
||||
for _, name := range names {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func linuxDesktopEntries() []linuxDesktopEntry {
|
||||
home, _ := os.UserHomeDir()
|
||||
roots := []string{"/usr/share/applications", "/usr/local/share/applications"}
|
||||
if home != "" {
|
||||
roots = append([]string{filepath.Join(home, ".local", "share", "applications")}, roots...)
|
||||
}
|
||||
var entries []linuxDesktopEntry
|
||||
for _, root := range roots {
|
||||
files, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, file := range files {
|
||||
if file.IsDir() || !strings.HasSuffix(strings.ToLower(file.Name()), ".desktop") {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(root, file.Name())
|
||||
if entry, ok := parseLinuxDesktopEntry(path); ok {
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func parseLinuxDesktopEntry(path string) (linuxDesktopEntry, bool) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return linuxDesktopEntry{}, false
|
||||
}
|
||||
defer file.Close()
|
||||
entry := linuxDesktopEntry{Path: path, ID: strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))}
|
||||
inDesktopEntry := false
|
||||
hidden := false
|
||||
entryType := ""
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
inDesktopEntry = line == "[Desktop Entry]"
|
||||
continue
|
||||
}
|
||||
if !inDesktopEntry || line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(key) {
|
||||
case "Name":
|
||||
entry.Name = strings.TrimSpace(value)
|
||||
case "Icon":
|
||||
entry.Icon = strings.TrimSpace(value)
|
||||
case "Type":
|
||||
entryType = strings.TrimSpace(value)
|
||||
case "Hidden":
|
||||
hidden = strings.EqualFold(strings.TrimSpace(value), "true")
|
||||
}
|
||||
}
|
||||
if scanner.Err() != nil || hidden || (entryType != "" && entryType != "Application") || entry.Name == "" {
|
||||
return linuxDesktopEntry{}, false
|
||||
}
|
||||
return entry, true
|
||||
}
|
||||
|
||||
func normalizeLinuxAppName(value string) string {
|
||||
value = strings.ToLower(value)
|
||||
return strings.NewReplacer(" ", "", "-", "", "_", "", ".", "").Replace(value)
|
||||
}
|
||||
|
||||
func findLinuxDesktopEntry(entries []linuxDesktopEntry, aliases ...string) (linuxDesktopEntry, bool) {
|
||||
for _, alias := range aliases {
|
||||
want := normalizeLinuxAppName(alias)
|
||||
for _, entry := range entries {
|
||||
if normalizeLinuxAppName(entry.ID) == want || normalizeLinuxAppName(entry.Name) == want {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, alias := range aliases {
|
||||
want := normalizeLinuxAppName(alias)
|
||||
if len(want) < 5 {
|
||||
continue
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(normalizeLinuxAppName(entry.ID), want) || strings.Contains(normalizeLinuxAppName(entry.Name), want) {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return linuxDesktopEntry{}, false
|
||||
}
|
||||
|
||||
func linuxDefaultDirectoryDesktopEntry(entries []linuxDesktopEntry) (linuxDesktopEntry, bool) {
|
||||
xdgMime := linuxExecutable("xdg-mime")
|
||||
if xdgMime != "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
out, err := exec.CommandContext(ctx, xdgMime, "query", "default", "inode/directory").Output()
|
||||
cancel()
|
||||
if err == nil {
|
||||
id := strings.TrimSpace(string(out))
|
||||
if entry, ok := findLinuxDesktopEntry(entries, id, strings.TrimSuffix(id, ".desktop")); ok {
|
||||
return entry, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return findLinuxDesktopEntry(entries, "org.gnome.Nautilus", "org.kde.dolphin", "thunar")
|
||||
}
|
||||
|
||||
func resolveLinuxDesktopIcon(icon string) string {
|
||||
icon = strings.TrimSpace(icon)
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
if filepath.IsAbs(icon) {
|
||||
if info, err := os.Stat(icon); err == nil && !info.IsDir() {
|
||||
return icon
|
||||
}
|
||||
return ""
|
||||
}
|
||||
ext := filepath.Ext(icon)
|
||||
name := strings.TrimSuffix(icon, ext)
|
||||
exts := []string{".png", ".svg", ".webp", ".jpg"}
|
||||
if ext != "" {
|
||||
exts = []string{ext}
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
iconRoots := []string{"/usr/share/icons", "/usr/local/share/icons"}
|
||||
if home != "" {
|
||||
iconRoots = append([]string{filepath.Join(home, ".local", "share", "icons"), filepath.Join(home, ".icons")}, iconRoots...)
|
||||
}
|
||||
sizes := []string{"64x64", "128x128", "48x48", "256x256", "512x512", "scalable"}
|
||||
for _, root := range iconRoots {
|
||||
for _, size := range sizes {
|
||||
for _, suffix := range exts {
|
||||
patterns := []string{
|
||||
filepath.Join(root, "hicolor", size, "apps", name+suffix),
|
||||
filepath.Join(root, "*", size, "apps", name+suffix),
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
matches, _ := filepath.Glob(pattern)
|
||||
for _, match := range matches {
|
||||
if info, err := os.Stat(match); err == nil && !info.IsDir() {
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, root := range []string{"/usr/share/pixmaps", "/usr/local/share/pixmaps"} {
|
||||
for _, suffix := range exts {
|
||||
path := filepath.Join(root, name+suffix)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func platformExternalOpenerSpecs() []externalOpenerSpec {
|
||||
entries := linuxDesktopEntries()
|
||||
gio := linuxExecutable("gio")
|
||||
var specs []externalOpenerSpec
|
||||
add := func(id, name, kind, mode string, executables, desktopAliases []string) {
|
||||
target := linuxExecutable(executables...)
|
||||
entry, hasEntry := findLinuxDesktopEntry(entries, desktopAliases...)
|
||||
launchMode := mode
|
||||
if target == "" && hasEntry && gio != "" {
|
||||
target = entry.Path
|
||||
launchMode = "gio"
|
||||
}
|
||||
if target == "" {
|
||||
return
|
||||
}
|
||||
iconSource := ""
|
||||
if hasEntry {
|
||||
iconSource = resolveLinuxDesktopIcon(entry.Icon)
|
||||
}
|
||||
specs = append(specs, externalOpenerSpec{
|
||||
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
|
||||
Target: target,
|
||||
LaunchMode: launchMode,
|
||||
IconSource: iconSource,
|
||||
})
|
||||
}
|
||||
|
||||
add("vscode", "VS Code", externalOpenerEditor, "path", []string{"code"}, []string{"code", "visual-studio-code", "com.visualstudio.code"})
|
||||
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "path", []string{"code-insiders"}, []string{"code-insiders", "visual-studio-code-insiders"})
|
||||
add("cursor", "Cursor", externalOpenerEditor, "path", []string{"cursor"}, []string{"cursor", "com.todesktop.230313mzl4w4u92"})
|
||||
if target := linuxExecutable("xdg-open"); target != "" {
|
||||
name := "File Manager"
|
||||
iconSource := ""
|
||||
if entry, ok := linuxDefaultDirectoryDesktopEntry(entries); ok {
|
||||
name = entry.Name
|
||||
iconSource = resolveLinuxDesktopIcon(entry.Icon)
|
||||
}
|
||||
specs = append(specs, externalOpenerSpec{
|
||||
View: ExternalOpenerView{ID: "file-manager", Name: name, Kind: externalOpenerFileManager},
|
||||
Target: target,
|
||||
LaunchMode: "path",
|
||||
IconSource: iconSource,
|
||||
})
|
||||
}
|
||||
add("ghostty", "Ghostty", externalOpenerTerminal, "ghostty", []string{"ghostty"}, []string{"com.mitchellh.ghostty", "ghostty"})
|
||||
add("gnome-terminal", "GNOME Terminal", externalOpenerTerminal, "gnome-terminal", []string{"gnome-terminal"}, []string{"org.gnome.Terminal", "gnome-terminal"})
|
||||
add("konsole", "Konsole", externalOpenerTerminal, "konsole", []string{"konsole"}, []string{"org.kde.konsole", "konsole"})
|
||||
add("kitty", "Kitty", externalOpenerTerminal, "kitty", []string{"kitty"}, []string{"kitty"})
|
||||
add("alacritty", "Alacritty", externalOpenerTerminal, "alacritty", []string{"alacritty"}, []string{"Alacritty", "alacritty"})
|
||||
add("terminal", "Terminal", externalOpenerTerminal, "cwd", []string{"x-terminal-emulator"}, []string{"terminal"})
|
||||
add("android-studio", "Android Studio", externalOpenerEditor, "path", []string{"android-studio", "studio", "studio.sh"}, []string{"android-studio", "com.google.AndroidStudio"})
|
||||
add("goland", "GoLand", externalOpenerEditor, "path", []string{"goland", "goland.sh"}, []string{"jetbrains-goland", "goland"})
|
||||
add("pycharm", "PyCharm", externalOpenerEditor, "path", []string{"pycharm", "pycharm.sh"}, []string{"jetbrains-pycharm", "pycharm"})
|
||||
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "path", []string{"idea", "idea.sh", "intellij-idea"}, []string{"jetbrains-idea", "intellij-idea"})
|
||||
add("webstorm", "WebStorm", externalOpenerEditor, "path", []string{"webstorm", "webstorm.sh"}, []string{"jetbrains-webstorm", "webstorm"})
|
||||
add("datagrip", "DataGrip", externalOpenerEditor, "path", []string{"datagrip", "datagrip.sh"}, []string{"jetbrains-datagrip", "datagrip"})
|
||||
add("codebuddy", "CodeBuddy", externalOpenerEditor, "path", []string{"codebuddy"}, []string{"codebuddy", "com.tencent.codebuddy"})
|
||||
add("windsurf", "Windsurf", externalOpenerEditor, "path", []string{"windsurf"}, []string{"windsurf", "com.exafunction.windsurf"})
|
||||
add("zed", "Zed", externalOpenerEditor, "path", []string{"zed"}, []string{"dev.zed.Zed", "zed"})
|
||||
add("sublime-text", "Sublime Text", externalOpenerEditor, "path", []string{"subl", "sublime_text"}, []string{"sublime_text", "sublime-text"})
|
||||
add("kiro", "Kiro", externalOpenerEditor, "path", []string{"kiro"}, []string{"kiro", "dev.kiro.desktop"})
|
||||
return specs
|
||||
}
|
||||
|
||||
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
|
||||
return externalOpenerIconFileDataURL(spec.IconSource)
|
||||
}
|
||||
|
||||
func linuxExternalOpenerCommand(spec externalOpenerSpec, path string) (*exec.Cmd, error) {
|
||||
target := spec.Target
|
||||
launchPath := externalOpenerLaunchPath(spec, path)
|
||||
var args []string
|
||||
switch spec.LaunchMode {
|
||||
case "path":
|
||||
args = []string{path}
|
||||
case "gio":
|
||||
args = []string{"launch", spec.Target, path}
|
||||
target = linuxExecutable("gio")
|
||||
case "ghostty", "gnome-terminal":
|
||||
args = []string{"--working-directory=" + launchPath}
|
||||
case "konsole":
|
||||
args = []string{"--workdir", launchPath}
|
||||
case "kitty":
|
||||
args = []string{"--directory", launchPath}
|
||||
case "alacritty":
|
||||
args = []string{"--working-directory", launchPath}
|
||||
}
|
||||
if target == "" {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
cmd := exec.Command(target, args...)
|
||||
cmd.Dir = externalOpenerWorkingDirectory(path)
|
||||
return cmd, nil
|
||||
}
|
||||
|
||||
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, _ bool) error {
|
||||
cmd, err := linuxExternalOpenerCommand(spec, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return startDetachedExternalOpener(cmd)
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
//go:build !darwin && !windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeLinuxDesktopFile(t *testing.T, dir, name, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestParseLinuxDesktopEntryReadsDesktopEntrySectionOnly(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeLinuxDesktopFile(t, dir, "code.desktop", `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Visual Studio Code
|
||||
Icon=vscode
|
||||
|
||||
[Desktop Action new-window]
|
||||
Name=New Window
|
||||
Icon=new-window
|
||||
`)
|
||||
entry, ok := parseLinuxDesktopEntry(path)
|
||||
if !ok {
|
||||
t.Fatal("valid desktop entry was rejected")
|
||||
}
|
||||
want := linuxDesktopEntry{Path: path, ID: "code", Name: "Visual Studio Code", Icon: "vscode"}
|
||||
if entry != want {
|
||||
t.Fatalf("parseLinuxDesktopEntry = %+v, want %+v", entry, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLinuxDesktopEntryRejectsHiddenAndNonApplications(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, content := range map[string]string{
|
||||
"hidden.desktop": "[Desktop Entry]\nType=Application\nName=Ghost\nHidden=true\n",
|
||||
"link.desktop": "[Desktop Entry]\nType=Link\nName=Bookmark\n",
|
||||
"unnamed.desktop": "[Desktop Entry]\nType=Application\n",
|
||||
"wrong-section.desktop": "[Desktop Action open]\nName=Open\n",
|
||||
} {
|
||||
path := writeLinuxDesktopFile(t, dir, name, content)
|
||||
if entry, ok := parseLinuxDesktopEntry(path); ok {
|
||||
t.Errorf("%s was accepted as %+v, want rejection", name, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindLinuxDesktopEntryMatchesNormalizedAliasesThenSubstrings(t *testing.T) {
|
||||
entries := []linuxDesktopEntry{
|
||||
{ID: "org.gnome.Terminal", Name: "Terminal"},
|
||||
{ID: "code", Name: "Visual Studio Code"},
|
||||
{ID: "jetbrains-idea", Name: "IntelliJ IDEA Ultimate Edition"},
|
||||
{ID: "kitty", Name: "kitty"},
|
||||
}
|
||||
if entry, ok := findLinuxDesktopEntry(entries, "org.gnome.terminal"); !ok || entry.ID != "org.gnome.Terminal" {
|
||||
t.Fatalf("normalized id lookup = (%+v, %v), want org.gnome.Terminal", entry, ok)
|
||||
}
|
||||
if entry, ok := findLinuxDesktopEntry(entries, "visual-studio-code"); !ok || entry.ID != "code" {
|
||||
t.Fatalf("normalized name lookup = (%+v, %v), want code", entry, ok)
|
||||
}
|
||||
if entry, ok := findLinuxDesktopEntry(entries, "intellij-idea"); !ok || entry.ID != "jetbrains-idea" {
|
||||
t.Fatalf("substring lookup = (%+v, %v), want jetbrains-idea", entry, ok)
|
||||
}
|
||||
if entry, ok := findLinuxDesktopEntry(entries, "kit"); ok {
|
||||
t.Fatalf("short alias %q unexpectedly matched %+v via substring", "kit", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxExternalOpenerCommandPerTerminalArguments(t *testing.T) {
|
||||
const workdir = "/tmp/reasonix workspace"
|
||||
cases := []struct {
|
||||
mode string
|
||||
target string
|
||||
want []string
|
||||
}{
|
||||
{"path", "/usr/bin/code", []string{"/usr/bin/code", workdir}},
|
||||
{"ghostty", "/usr/bin/ghostty", []string{"/usr/bin/ghostty", "--working-directory=" + workdir}},
|
||||
{"gnome-terminal", "/usr/bin/gnome-terminal", []string{"/usr/bin/gnome-terminal", "--working-directory=" + workdir}},
|
||||
{"konsole", "/usr/bin/konsole", []string{"/usr/bin/konsole", "--workdir", workdir}},
|
||||
{"kitty", "/usr/bin/kitty", []string{"/usr/bin/kitty", "--directory", workdir}},
|
||||
{"alacritty", "/usr/bin/alacritty", []string{"/usr/bin/alacritty", "--working-directory", workdir}},
|
||||
{"cwd", "/usr/bin/x-terminal-emulator", []string{"/usr/bin/x-terminal-emulator"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
spec := externalOpenerSpec{Target: tc.target, LaunchMode: tc.mode}
|
||||
cmd, err := linuxExternalOpenerCommand(spec, workdir)
|
||||
if err != nil {
|
||||
t.Errorf("%s: linuxExternalOpenerCommand error = %v", tc.mode, err)
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(cmd.Args, tc.want) {
|
||||
t.Errorf("%s: args = %#v, want %#v", tc.mode, cmd.Args, tc.want)
|
||||
}
|
||||
if cmd.Dir != workdir {
|
||||
t.Errorf("%s: dir = %q, want workspace directory", tc.mode, cmd.Dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxExternalOpenerCommandUsesParentForRegularFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "report.md")
|
||||
if err := os.WriteFile(path, []byte("report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
spec externalOpenerSpec
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "editor",
|
||||
spec: externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerEditor}, Target: "/usr/bin/code", LaunchMode: "path"},
|
||||
want: []string{"/usr/bin/code", path},
|
||||
},
|
||||
{
|
||||
name: "terminal",
|
||||
spec: externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerTerminal}, Target: "/usr/bin/ghostty", LaunchMode: "ghostty"},
|
||||
want: []string{"/usr/bin/ghostty", "--working-directory=" + dir},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cmd, err := linuxExternalOpenerCommand(tc.spec, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(cmd.Args, tc.want) {
|
||||
t.Fatalf("args = %#v, want %#v", cmd.Args, tc.want)
|
||||
}
|
||||
if cmd.Dir != dir {
|
||||
t.Fatalf("dir = %q, want parent directory %q", cmd.Dir, dir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLinuxExternalOpenerCommandLaunchesDesktopEntriesViaGio(t *testing.T) {
|
||||
binDir := t.TempDir()
|
||||
gio := filepath.Join(binDir, "gio")
|
||||
if err := os.WriteFile(gio, []byte("#!/bin/sh\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
spec := externalOpenerSpec{Target: "/usr/share/applications/code.desktop", LaunchMode: "gio"}
|
||||
cmd, err := linuxExternalOpenerCommand(spec, "/tmp/project")
|
||||
if err != nil {
|
||||
t.Fatalf("linuxExternalOpenerCommand error = %v", err)
|
||||
}
|
||||
want := []string{gio, "launch", "/usr/share/applications/code.desktop", "/tmp/project"}
|
||||
if !reflect.DeepEqual(cmd.Args, want) {
|
||||
t.Fatalf("gio args = %#v, want %#v", cmd.Args, want)
|
||||
}
|
||||
|
||||
t.Setenv("PATH", t.TempDir())
|
||||
if _, err := linuxExternalOpenerCommand(spec, "/tmp/project"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("missing gio error = %v, want os.ErrNotExist", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveLinuxDesktopIconAcceptsOnlyExistingAbsoluteFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
icon := filepath.Join(dir, "vscode.png")
|
||||
if err := os.WriteFile(icon, []byte("png"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := resolveLinuxDesktopIcon(icon); got != icon {
|
||||
t.Fatalf("existing absolute icon = %q, want %q", got, icon)
|
||||
}
|
||||
if got := resolveLinuxDesktopIcon(filepath.Join(dir, "missing.png")); got != "" {
|
||||
t.Fatalf("missing absolute icon = %q, want empty", got)
|
||||
}
|
||||
if got := resolveLinuxDesktopIcon(dir); got != "" {
|
||||
t.Fatalf("directory icon = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -1,371 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testExternalOpener(id, name, kind string) externalOpenerSpec {
|
||||
return externalOpenerSpec{View: ExternalOpenerView{ID: id, Name: name, Kind: kind}, Target: id}
|
||||
}
|
||||
|
||||
func TestResolveExternalOpenerPrefersInstalledSelection(t *testing.T) {
|
||||
specs := []externalOpenerSpec{
|
||||
testExternalOpener("files", "Files", externalOpenerFileManager),
|
||||
testExternalOpener("code", "Code", externalOpenerEditor),
|
||||
}
|
||||
got, ok := resolveExternalOpener(specs, " CODE ")
|
||||
if !ok || got.View.ID != "code" {
|
||||
t.Fatalf("resolveExternalOpener = (%+v, %v), want installed code", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExternalOpenerFallsBackAcrossOperatingSystems(t *testing.T) {
|
||||
specs := []externalOpenerSpec{
|
||||
testExternalOpener("files", "Files", externalOpenerFileManager),
|
||||
testExternalOpener("code", "Code", externalOpenerEditor),
|
||||
}
|
||||
got, ok := resolveExternalOpener(specs, "finder")
|
||||
if !ok || got.View.ID != "files" {
|
||||
t.Fatalf("resolveExternalOpener unavailable preference = (%+v, %v), want file manager fallback", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerViewsAreStableAndDeduplicated(t *testing.T) {
|
||||
specs := []externalOpenerSpec{
|
||||
testExternalOpener("code", "VS Code", externalOpenerEditor),
|
||||
testExternalOpener("CODE", "Duplicate", externalOpenerEditor),
|
||||
testExternalOpener("", "Invalid", externalOpenerEditor),
|
||||
}
|
||||
want := []ExternalOpenerView{{ID: "code", Name: "VS Code", Kind: externalOpenerEditor}}
|
||||
if got := externalOpenerViews(specs); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("externalOpenerViews = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerCatalogCachesUntilTTLExpires(t *testing.T) {
|
||||
now := time.Unix(100, 0)
|
||||
discoveryCalls := 0
|
||||
cache := newExternalOpenerCatalogCache(15*time.Second, func() []externalOpenerSpec {
|
||||
discoveryCalls++
|
||||
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
|
||||
})
|
||||
cache.now = func() time.Time { return now }
|
||||
|
||||
first := cache.get()
|
||||
first[0].View.Name = "mutated"
|
||||
if got := cache.get(); discoveryCalls != 1 || got[0].View.Name != "Code" {
|
||||
t.Fatalf("fresh cache = (%d calls, %+v), want one isolated discovery result", discoveryCalls, got)
|
||||
}
|
||||
|
||||
now = now.Add(15 * time.Second)
|
||||
if got := cache.get(); discoveryCalls != 2 || got[0].View.Name != "Code" {
|
||||
t.Fatalf("expired cache = (%d calls, %+v), want a refreshed result", discoveryCalls, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerCatalogCoalescesConcurrentRefreshes(t *testing.T) {
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var callsMu sync.Mutex
|
||||
discoveryCalls := 0
|
||||
cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec {
|
||||
callsMu.Lock()
|
||||
discoveryCalls++
|
||||
callsMu.Unlock()
|
||||
close(started)
|
||||
<-release
|
||||
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
|
||||
})
|
||||
|
||||
const callers = 8
|
||||
results := make(chan []externalOpenerSpec, callers)
|
||||
for range callers {
|
||||
go func() { results <- cache.get() }()
|
||||
}
|
||||
<-started
|
||||
close(release)
|
||||
for range callers {
|
||||
if got := <-results; len(got) != 1 || got[0].View.ID != "code" {
|
||||
t.Fatalf("coalesced cache result = %+v, want code", got)
|
||||
}
|
||||
}
|
||||
callsMu.Lock()
|
||||
defer callsMu.Unlock()
|
||||
if discoveryCalls != 1 {
|
||||
t.Fatalf("concurrent discovery calls = %d, want 1", discoveryCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkExternalOpenerCatalogCacheHit(b *testing.B) {
|
||||
cache := newExternalOpenerCatalogCache(time.Minute, func() []externalOpenerSpec {
|
||||
return []externalOpenerSpec{testExternalOpener("code", "Code", externalOpenerEditor)}
|
||||
})
|
||||
cache.get()
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
cache.get()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlatformExternalOpenersHaveUniqueSafeIds(t *testing.T) {
|
||||
specs := platformExternalOpenerSpecs()
|
||||
if len(specs) == 0 {
|
||||
t.Fatal("platformExternalOpenerSpecs returned no fallback opener")
|
||||
}
|
||||
views := externalOpenerViews(specs)
|
||||
if len(views) != len(specs) {
|
||||
t.Fatalf("platform opener ids are invalid or duplicated: specs=%+v views=%+v", specs, views)
|
||||
}
|
||||
if _, ok := resolveExternalOpener(specs, "definitely-not-installed"); !ok {
|
||||
t.Fatal("platform opener list has no usable fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetPreferredExternalOpenerRejectsRendererCommands(t *testing.T) {
|
||||
app := NewApp()
|
||||
for _, id := range []string{"", "../../bin/sh", "vscode; rm -rf /"} {
|
||||
if err := app.SetPreferredExternalOpener(id); err == nil {
|
||||
t.Fatalf("SetPreferredExternalOpener(%q) unexpectedly succeeded", id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerWorkspaceCapabilityUsesTheTabDirectoryNotScope(t *testing.T) {
|
||||
projectRoot := t.TempDir()
|
||||
globalRoot := t.TempDir()
|
||||
missingRoot := filepath.Join(t.TempDir(), "missing")
|
||||
fileRoot := filepath.Join(t.TempDir(), "not-a-directory")
|
||||
if err := os.WriteFile(fileRoot, []byte("file"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"project": {ID: "project", Scope: "project", WorkspaceRoot: projectRoot},
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: globalRoot},
|
||||
"missing": {ID: "missing", Scope: "project", WorkspaceRoot: missingRoot},
|
||||
"file": {ID: "file", Scope: "global", WorkspaceRoot: fileRoot},
|
||||
"empty": {ID: "empty", Scope: "global"},
|
||||
}
|
||||
|
||||
for _, tabID := range []string{"project", "global"} {
|
||||
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err != nil {
|
||||
t.Errorf("externalOpenerWorkspacePathForTab(%q) = %v, want available", tabID, err)
|
||||
}
|
||||
}
|
||||
for _, tabID := range []string{"missing", "file", "empty", "unknown"} {
|
||||
if _, err := app.externalOpenerWorkspacePathForTab(tabID); err == nil {
|
||||
t.Errorf("externalOpenerWorkspacePathForTab(%q) succeeded, want unavailable", tabID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenersForGlobalTabReportsWorkspaceCapability(t *testing.T) {
|
||||
app := NewApp()
|
||||
app.tabs = map[string]*WorkspaceTab{
|
||||
"global": {ID: "global", Scope: "global", WorkspaceRoot: t.TempDir()},
|
||||
}
|
||||
view := app.ExternalOpenersForTab("global")
|
||||
if !view.WorkspaceOpenable {
|
||||
t.Fatal("ExternalOpenersForTab(global) workspaceOpenable = false, want true")
|
||||
}
|
||||
if view.Openers == nil {
|
||||
t.Fatal("ExternalOpenersForTab(global) openers = nil, want a Wails-safe array")
|
||||
}
|
||||
unavailable := app.ExternalOpenersForTab("unknown")
|
||||
if unavailable.WorkspaceOpenable || unavailable.Openers == nil {
|
||||
t.Fatalf("ExternalOpenersForTab(unknown) = %+v, want unavailable with an empty Wails-safe array", unavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalSaveDestinationIsSourceDetectsFilesystemAliases(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "Readme.md")
|
||||
if err := os.WriteFile(source, []byte("content"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
info, err := os.Stat(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
hardLink := filepath.Join(dir, "readme-hardlink.md")
|
||||
if err := os.Link(source, hardLink); err != nil {
|
||||
t.Fatalf("create hard link: %v", err)
|
||||
}
|
||||
if same, err := localSaveDestinationIsSource(info, hardLink); err != nil || !same {
|
||||
t.Fatalf("hard-link alias = (%v, %v), want (true, nil)", same, err)
|
||||
}
|
||||
|
||||
symlink := filepath.Join(dir, "readme-symlink.md")
|
||||
if err := os.Symlink(source, symlink); err == nil {
|
||||
if same, err := localSaveDestinationIsSource(info, symlink); err != nil || !same {
|
||||
t.Fatalf("symlink alias = (%v, %v), want (true, nil)", same, err)
|
||||
}
|
||||
}
|
||||
|
||||
missing := filepath.Join(dir, "new-copy.md")
|
||||
if same, err := localSaveDestinationIsSource(info, missing); err != nil || same {
|
||||
t.Fatalf("missing destination = (%v, %v), want (false, nil)", same, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyLocalPathAsRejectsAliasWithoutChangingSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.md")
|
||||
alias := filepath.Join(dir, "alias.md")
|
||||
content := []byte("source content")
|
||||
if err := os.WriteFile(source, content, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Link(source, alias); err != nil {
|
||||
t.Fatalf("create hard link: %v", err)
|
||||
}
|
||||
if err := copyLocalPathAs(source, alias); err == nil {
|
||||
t.Fatal("copyLocalPathAs(alias) succeeded, want same-source error")
|
||||
}
|
||||
if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, content) {
|
||||
t.Fatalf("source after rejected alias copy = (%q, %v), want original content", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCopyLocalPathAsReplacesDestinationWithoutChangingSource(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
source := filepath.Join(dir, "source.md")
|
||||
target := filepath.Join(dir, "target.md")
|
||||
sourceContent := []byte("source content")
|
||||
if err := os.WriteFile(source, sourceContent, 0o640); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(target, []byte("old destination"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := copyLocalPathAs(source, target); err != nil {
|
||||
t.Fatalf("copyLocalPathAs = %v", err)
|
||||
}
|
||||
if got, err := os.ReadFile(target); err != nil || !reflect.DeepEqual(got, sourceContent) {
|
||||
t.Fatalf("destination after copy = (%q, %v), want source content", got, err)
|
||||
}
|
||||
if got, err := os.ReadFile(source); err != nil || !reflect.DeepEqual(got, sourceContent) {
|
||||
t.Fatalf("source after copy = (%q, %v), want unchanged source", got, err)
|
||||
}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".target.md.reasonix-copy-") {
|
||||
t.Fatalf("temporary copy was not cleaned up: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerLaunchPathUsesParentForTerminalFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "report.md")
|
||||
if err := os.WriteFile(path, []byte("report"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
terminal := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerTerminal}, LaunchMode: "application"}
|
||||
if got := externalOpenerLaunchPath(terminal, path); got != dir {
|
||||
t.Fatalf("terminal launch path = %q, want parent directory %q", got, dir)
|
||||
}
|
||||
editor := externalOpenerSpec{View: ExternalOpenerView{Kind: externalOpenerEditor}, LaunchMode: "application"}
|
||||
if got := externalOpenerLaunchPath(editor, path); got != path {
|
||||
t.Fatalf("editor launch path = %q, want file %q", got, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerIconFileDataURLAcceptsBoundedImages(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "icon.png")
|
||||
if err := os.WriteFile(path, []byte("png-data"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := externalOpenerIconFileDataURL(path)
|
||||
if !strings.HasPrefix(got, "data:image/png;base64,") {
|
||||
t.Fatalf("externalOpenerIconFileDataURL = %q, want PNG data URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerPNGRestoresAlphaFromBlackAndWhiteComposites(t *testing.T) {
|
||||
black := []byte{
|
||||
0, 0, 0, 255,
|
||||
0, 0, 0, 255,
|
||||
0, 0, 128, 255,
|
||||
}
|
||||
white := []byte{
|
||||
0, 0, 0, 255,
|
||||
255, 255, 255, 255,
|
||||
127, 127, 255, 255,
|
||||
}
|
||||
encoded := externalOpenerPNGFromBGRAComposites(black, white, 3, 1)
|
||||
decoded, err := png.Decode(bytes.NewReader(encoded))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []color.NRGBA{
|
||||
{R: 0, G: 0, B: 0, A: 255},
|
||||
{0, 0, 0, 0},
|
||||
{R: 255, G: 0, B: 0, A: 128},
|
||||
}
|
||||
for x, expected := range want {
|
||||
got := color.NRGBAModel.Convert(decoded.At(x, 0)).(color.NRGBA)
|
||||
if got != expected {
|
||||
t.Fatalf("pixel %d = %#v, want %#v", x, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalOpenerViewIconIsBackwardCompatible(t *testing.T) {
|
||||
withoutIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(withoutIcon), "iconDataUrl") {
|
||||
t.Fatalf("empty optional icon should be omitted: %s", withoutIcon)
|
||||
}
|
||||
withIcon, err := json.Marshal(ExternalOpenerView{ID: "vscode", Name: "VS Code", Kind: externalOpenerEditor, IconDataURL: "data:image/png;base64,AA=="})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(withIcon), `"iconDataUrl":"data:image/png;base64,AA=="`) {
|
||||
t.Fatalf("native icon missing from JSON contract: %s", withIcon)
|
||||
}
|
||||
|
||||
withoutCapability, err := json.Marshal(ExternalOpenersView{Openers: []ExternalOpenerView{}, Preferred: "finder"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(withoutCapability), "workspaceOpenable") {
|
||||
t.Fatalf("false workspace capability should be omitted for old readers: %s", withoutCapability)
|
||||
}
|
||||
withCapability, err := json.Marshal(ExternalOpenersView{
|
||||
Openers: []ExternalOpenerView{},
|
||||
Preferred: "finder",
|
||||
WorkspaceOpenable: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(withCapability), `"workspaceOpenable":true`) {
|
||||
t.Fatalf("workspace capability missing from JSON contract: %s", withCapability)
|
||||
}
|
||||
var oldReader struct {
|
||||
Openers []ExternalOpenerView `json:"openers"`
|
||||
Preferred string `json:"preferred"`
|
||||
}
|
||||
if err := json.Unmarshal(withCapability, &oldReader); err != nil || oldReader.Preferred != "finder" || oldReader.Openers == nil {
|
||||
t.Fatalf("old reader rejected additive workspace capability: reader=%+v err=%v", oldReader, err)
|
||||
}
|
||||
}
|
||||
@@ -1,395 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
)
|
||||
|
||||
const (
|
||||
shgfiIcon = 0x000000100
|
||||
shgfiLargeIcon = 0x000000000
|
||||
dibRGBColors = 0
|
||||
drawIconNormal = 0x0003
|
||||
windowsOpenerIconWidth = 32
|
||||
)
|
||||
|
||||
type windowsShellFileInfo struct {
|
||||
Icon windows.Handle
|
||||
IconIndex int32
|
||||
Attributes uint32
|
||||
DisplayName [260]uint16
|
||||
TypeName [80]uint16
|
||||
}
|
||||
|
||||
type windowsBitmapInfoHeader struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ColorsUsed uint32
|
||||
ColorsNeeded uint32
|
||||
}
|
||||
|
||||
type windowsBitmapInfo struct {
|
||||
Header windowsBitmapInfoHeader
|
||||
Colors [1]uint32
|
||||
}
|
||||
|
||||
var (
|
||||
windowsShell32 = windows.NewLazySystemDLL("shell32.dll")
|
||||
windowsUser32 = windows.NewLazySystemDLL("user32.dll")
|
||||
windowsGDI32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||
windowsSHGetFileInfo = windowsShell32.NewProc("SHGetFileInfoW")
|
||||
windowsDestroyIcon = windowsUser32.NewProc("DestroyIcon")
|
||||
windowsDrawIconEx = windowsUser32.NewProc("DrawIconEx")
|
||||
windowsGetDC = windowsUser32.NewProc("GetDC")
|
||||
windowsReleaseDC = windowsUser32.NewProc("ReleaseDC")
|
||||
windowsCreateCompatibleDC = windowsGDI32.NewProc("CreateCompatibleDC")
|
||||
windowsCreateDIBSection = windowsGDI32.NewProc("CreateDIBSection")
|
||||
windowsSelectObject = windowsGDI32.NewProc("SelectObject")
|
||||
windowsDeleteObject = windowsGDI32.NewProc("DeleteObject")
|
||||
windowsDeleteDC = windowsGDI32.NewProc("DeleteDC")
|
||||
)
|
||||
|
||||
func joinWindowsInstallPath(root string, parts ...string) string {
|
||||
if root == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(append([]string{root}, parts...)...)
|
||||
}
|
||||
|
||||
func firstWindowsExecutable(names []string, candidates ...string) string {
|
||||
for _, name := range names {
|
||||
if path, err := exec.LookPath(name); err == nil {
|
||||
return path
|
||||
}
|
||||
if path := windowsAppPathExecutable(name); path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
if matches, _ := filepath.Glob(candidate); len(matches) > 0 {
|
||||
for _, match := range matches {
|
||||
if info, err := os.Stat(match); err == nil && !info.IsDir() {
|
||||
return match
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsAppPathExecutable resolves an install registered under
|
||||
// HKCU/HKLM ...\App Paths\<name>. Custom VS Code / editor installs often only
|
||||
// appear there, not on PATH or under the default Program Files trees.
|
||||
func windowsAppPathExecutable(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
subKey := `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\` + name
|
||||
for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} {
|
||||
key, err := registry.OpenKey(root, subKey, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
raw, _, err := key.GetStringValue("")
|
||||
key.Close()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
path := strings.Trim(strings.TrimSpace(raw), `"`)
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
path = os.ExpandEnv(path)
|
||||
if info, err := os.Stat(path); err == nil && !info.IsDir() {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsTerminalIconSource prefers a real Windows Terminal package binary for
|
||||
// SHGetFileInfo. Store installs expose wt.exe as an App Execution Alias (often
|
||||
// zero bytes under LocalAppData\Microsoft\WindowsApps). The package binary may
|
||||
// live under Program Files\WindowsApps, but non-elevated processes frequently
|
||||
// cannot enumerate that protected directory — so a renderable console-host
|
||||
// fallback must win over a zero-byte alias (see pickWindowsTerminalIconSource).
|
||||
func windowsTerminalIconSource(wtPath string) string {
|
||||
var resolved []windowsIconCandidate
|
||||
for _, candidate := range windowsTerminalIconCandidatePaths(
|
||||
wtPath,
|
||||
os.Getenv("LOCALAPPDATA"),
|
||||
os.Getenv("ProgramFiles"),
|
||||
) {
|
||||
matches := []string{candidate}
|
||||
if strings.ContainsAny(candidate, `*?[`) {
|
||||
globbed, err := filepath.Glob(candidate)
|
||||
if err != nil || len(globbed) == 0 {
|
||||
continue
|
||||
}
|
||||
matches = globbed
|
||||
}
|
||||
for _, match := range matches {
|
||||
info, err := os.Stat(match)
|
||||
if err != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
resolved = append(resolved, windowsIconCandidate{Path: match, Size: info.Size()})
|
||||
}
|
||||
}
|
||||
// Prefer a normal console host icon over a zero-byte wt.exe alias so the
|
||||
// menu never ships a blank glyph when WindowsApps is unreadable.
|
||||
renderable := firstWindowsExecutable([]string{"powershell.exe"},
|
||||
joinWindowsInstallPath(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||
if picked := pickWindowsTerminalIconSource(resolved, renderable); picked != "" {
|
||||
return picked
|
||||
}
|
||||
return strings.TrimSpace(wtPath)
|
||||
}
|
||||
|
||||
// shellExecuteOpenFile launches file via ShellExecuteW("open") without cmd.exe.
|
||||
// parameters and directory are optional; directory becomes the process CWD.
|
||||
func shellExecuteOpenFile(file, parameters, directory string) error {
|
||||
verb, err := windows.UTF16PtrFromString("open")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filePtr, err := windows.UTF16PtrFromString(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var paramsPtr *uint16
|
||||
if parameters != "" {
|
||||
paramsPtr, err = windows.UTF16PtrFromString(parameters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var dirPtr *uint16
|
||||
if directory != "" {
|
||||
dirPtr, err = windows.UTF16PtrFromString(directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return windows.ShellExecute(0, verb, filePtr, paramsPtr, dirPtr, windows.SW_SHOWNORMAL)
|
||||
}
|
||||
|
||||
func platformExternalOpenerSpecs() []externalOpenerSpec {
|
||||
local := os.Getenv("LOCALAPPDATA")
|
||||
programFiles := os.Getenv("ProgramFiles")
|
||||
programFilesX86 := os.Getenv("ProgramFiles(x86)")
|
||||
windowsDir := os.Getenv("WINDIR")
|
||||
var specs []externalOpenerSpec
|
||||
add := func(id, name, kind, mode string, names []string, candidates ...string) {
|
||||
if path := firstWindowsExecutable(names, candidates...); path != "" {
|
||||
specs = append(specs, externalOpenerSpec{
|
||||
View: ExternalOpenerView{ID: id, Name: name, Kind: kind},
|
||||
Target: path,
|
||||
LaunchMode: mode,
|
||||
IconSource: path,
|
||||
})
|
||||
}
|
||||
}
|
||||
jetbrainsProgram := func(product, executable string) string {
|
||||
return joinWindowsInstallPath(programFiles, "JetBrains", product+" *", "bin", executable)
|
||||
}
|
||||
jetbrainsToolbox := func(product, executable string) string {
|
||||
return joinWindowsInstallPath(local, "JetBrains", "Toolbox", "apps", product, "*", "*", "bin", executable)
|
||||
}
|
||||
|
||||
add("vscode", "VS Code", externalOpenerEditor, "path", []string{"Code.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "Microsoft VS Code", "Code.exe"),
|
||||
joinWindowsInstallPath(programFiles, "Microsoft VS Code", "Code.exe"),
|
||||
joinWindowsInstallPath(programFilesX86, "Microsoft VS Code", "Code.exe"))
|
||||
add("vscode-insiders", "VS Code Insiders", externalOpenerEditor, "path", []string{"Code - Insiders.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "Microsoft VS Code Insiders", "Code - Insiders.exe"))
|
||||
add("cursor", "Cursor", externalOpenerEditor, "path", []string{"Cursor.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "cursor", "Cursor.exe"),
|
||||
joinWindowsInstallPath(programFiles, "Cursor", "Cursor.exe"))
|
||||
add("file-explorer", "File Explorer", externalOpenerFileManager, "shell-open", []string{"explorer.exe"},
|
||||
joinWindowsInstallPath(windowsDir, "explorer.exe"))
|
||||
if wt := firstWindowsExecutable([]string{"wt.exe"}); wt != "" {
|
||||
specs = append(specs, externalOpenerSpec{
|
||||
View: ExternalOpenerView{ID: "windows-terminal", Name: "Windows Terminal", Kind: externalOpenerTerminal},
|
||||
Target: wt,
|
||||
LaunchMode: "windows-terminal",
|
||||
IconSource: windowsTerminalIconSource(wt),
|
||||
})
|
||||
}
|
||||
add("powershell", "PowerShell", externalOpenerTerminal, "console", []string{"pwsh.exe", "powershell.exe"},
|
||||
joinWindowsInstallPath(windowsDir, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||
add("command-prompt", "Command Prompt", externalOpenerTerminal, "console", []string{"cmd.exe"},
|
||||
joinWindowsInstallPath(windowsDir, "System32", "cmd.exe"))
|
||||
add("android-studio", "Android Studio", externalOpenerEditor, "path", []string{"studio64.exe", "studio.exe"},
|
||||
joinWindowsInstallPath(programFiles, "Android", "Android Studio", "bin", "studio64.exe"))
|
||||
add("goland", "GoLand", externalOpenerEditor, "path", []string{"goland64.exe", "goland.exe"},
|
||||
jetbrainsProgram("GoLand", "goland64.exe"), jetbrainsToolbox("GoLand", "goland64.exe"))
|
||||
add("pycharm", "PyCharm", externalOpenerEditor, "path", []string{"pycharm64.exe", "pycharm.exe"},
|
||||
jetbrainsProgram("PyCharm", "pycharm64.exe"), jetbrainsToolbox("PyCharm*", "pycharm64.exe"))
|
||||
add("intellij-idea", "IntelliJ IDEA", externalOpenerEditor, "path", []string{"idea64.exe", "idea.exe"},
|
||||
jetbrainsProgram("IntelliJ IDEA", "idea64.exe"), jetbrainsToolbox("IDEA*", "idea64.exe"))
|
||||
add("webstorm", "WebStorm", externalOpenerEditor, "path", []string{"webstorm64.exe", "webstorm.exe"},
|
||||
jetbrainsProgram("WebStorm", "webstorm64.exe"), jetbrainsToolbox("WebStorm", "webstorm64.exe"))
|
||||
add("datagrip", "DataGrip", externalOpenerEditor, "path", []string{"datagrip64.exe", "datagrip.exe"},
|
||||
jetbrainsProgram("DataGrip", "datagrip64.exe"), jetbrainsToolbox("DataGrip", "datagrip64.exe"))
|
||||
add("codebuddy", "CodeBuddy", externalOpenerEditor, "path", []string{"CodeBuddy.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "CodeBuddy", "CodeBuddy.exe"),
|
||||
joinWindowsInstallPath(programFiles, "CodeBuddy", "CodeBuddy.exe"))
|
||||
add("windsurf", "Windsurf", externalOpenerEditor, "path", []string{"Windsurf.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "Windsurf", "Windsurf.exe"))
|
||||
add("zed", "Zed", externalOpenerEditor, "path", []string{"zed.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "Zed", "zed.exe"))
|
||||
add("sublime-text", "Sublime Text", externalOpenerEditor, "path", []string{"sublime_text.exe"},
|
||||
joinWindowsInstallPath(programFiles, "Sublime Text", "sublime_text.exe"))
|
||||
add("kiro", "Kiro", externalOpenerEditor, "path", []string{"Kiro.exe"},
|
||||
joinWindowsInstallPath(local, "Programs", "Kiro", "Kiro.exe"))
|
||||
return specs
|
||||
}
|
||||
|
||||
func platformExternalOpenerIconDataURL(spec externalOpenerSpec) string {
|
||||
if spec.IconSource == "" {
|
||||
return ""
|
||||
}
|
||||
path, err := windows.UTF16PtrFromString(spec.IconSource)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var info windowsShellFileInfo
|
||||
result, _, _ := windowsSHGetFileInfo.Call(
|
||||
uintptr(unsafe.Pointer(path)),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(&info)),
|
||||
unsafe.Sizeof(info),
|
||||
shgfiIcon|shgfiLargeIcon,
|
||||
)
|
||||
if result == 0 || info.Icon == 0 {
|
||||
return ""
|
||||
}
|
||||
defer windowsDestroyIcon.Call(uintptr(info.Icon))
|
||||
|
||||
black, ok := renderWindowsExternalOpenerIcon(info.Icon, 0)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
white, ok := renderWindowsExternalOpenerIcon(info.Icon, 255)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return externalOpenerPNGDataURL(externalOpenerPNGFromBGRAComposites(
|
||||
black,
|
||||
white,
|
||||
windowsOpenerIconWidth,
|
||||
windowsOpenerIconWidth,
|
||||
))
|
||||
}
|
||||
|
||||
func renderWindowsExternalOpenerIcon(icon windows.Handle, background byte) ([]byte, bool) {
|
||||
screenDC, _, _ := windowsGetDC.Call(0)
|
||||
if screenDC == 0 {
|
||||
return nil, false
|
||||
}
|
||||
defer windowsReleaseDC.Call(0, screenDC)
|
||||
|
||||
memoryDC, _, _ := windowsCreateCompatibleDC.Call(screenDC)
|
||||
if memoryDC == 0 {
|
||||
return nil, false
|
||||
}
|
||||
defer windowsDeleteDC.Call(memoryDC)
|
||||
|
||||
bitmapInfo := windowsBitmapInfo{Header: windowsBitmapInfoHeader{
|
||||
Size: uint32(unsafe.Sizeof(windowsBitmapInfoHeader{})),
|
||||
Width: windowsOpenerIconWidth,
|
||||
Height: -windowsOpenerIconWidth,
|
||||
Planes: 1,
|
||||
BitCount: 32,
|
||||
Compression: 0,
|
||||
}}
|
||||
var bits unsafe.Pointer
|
||||
bitmap, _, _ := windowsCreateDIBSection.Call(
|
||||
memoryDC,
|
||||
uintptr(unsafe.Pointer(&bitmapInfo)),
|
||||
dibRGBColors,
|
||||
uintptr(unsafe.Pointer(&bits)),
|
||||
0,
|
||||
0,
|
||||
)
|
||||
if bitmap == 0 || bits == nil {
|
||||
return nil, false
|
||||
}
|
||||
defer windowsDeleteObject.Call(bitmap)
|
||||
|
||||
previous, _, _ := windowsSelectObject.Call(memoryDC, bitmap)
|
||||
if previous == 0 {
|
||||
return nil, false
|
||||
}
|
||||
defer windowsSelectObject.Call(memoryDC, previous)
|
||||
|
||||
pixels := unsafe.Slice((*byte)(bits), windowsOpenerIconWidth*windowsOpenerIconWidth*4)
|
||||
for offset := 0; offset < len(pixels); offset += 4 {
|
||||
pixels[offset] = background
|
||||
pixels[offset+1] = background
|
||||
pixels[offset+2] = background
|
||||
pixels[offset+3] = 255
|
||||
}
|
||||
drawn, _, _ := windowsDrawIconEx.Call(
|
||||
memoryDC,
|
||||
0,
|
||||
0,
|
||||
uintptr(icon),
|
||||
windowsOpenerIconWidth,
|
||||
windowsOpenerIconWidth,
|
||||
0,
|
||||
0,
|
||||
drawIconNormal,
|
||||
)
|
||||
if drawn == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return append([]byte(nil), pixels...), true
|
||||
}
|
||||
|
||||
func launchPlatformExternalOpener(spec externalOpenerSpec, path string, isDir bool) error {
|
||||
var cmd *exec.Cmd
|
||||
launchPath := externalOpenerLaunchPath(spec, path)
|
||||
switch spec.LaunchMode {
|
||||
case "shell-open":
|
||||
return openWorkspacePathWithType(path, isDir)
|
||||
case "path":
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
case "windows-terminal":
|
||||
// exec.Command uses CreateProcess argument escaping, not cmd.exe, so
|
||||
// workspace paths with shell metacharacters stay a single -d argument.
|
||||
cmd = exec.Command(spec.Target, "-d", launchPath)
|
||||
case "console":
|
||||
// Never route console openers through cmd.exe / start: working-directory
|
||||
// text would be re-parsed as shell syntax (& | ^ etc.). ShellExecute
|
||||
// opens the binary with lpDirectory set to the workspace path.
|
||||
plan := planWindowsConsoleLaunch(spec.Target, launchPath)
|
||||
if plan.File == "" {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
return shellExecuteOpenFile(plan.File, "", plan.Dir)
|
||||
default:
|
||||
cmd = exec.Command(spec.Target, path)
|
||||
}
|
||||
return startDetachedExternalOpener(cmd)
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// windowsTerminalIconCandidatePaths returns ordered icon lookup paths for
|
||||
// Windows Terminal. Store installs register only a wt.exe App Execution Alias
|
||||
// under LocalAppData\Microsoft\WindowsApps; the real WindowsTerminal.exe lives
|
||||
// under Program Files\WindowsApps\Microsoft.WindowsTerminal_*.
|
||||
//
|
||||
// Non-elevated processes often cannot glob the protected WindowsApps package
|
||||
// directory, so callers must treat a missing package hit as normal and apply
|
||||
// pickWindowsTerminalIconSource (renderable fallback before zero-byte aliases).
|
||||
//
|
||||
// Patterns may contain filepath globs; callers expand them on the host.
|
||||
func windowsTerminalIconCandidatePaths(wtPath, localAppData, programFiles string) []string {
|
||||
var out []string
|
||||
if programFiles = strings.TrimSpace(programFiles); programFiles != "" {
|
||||
out = append(out,
|
||||
filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe"),
|
||||
filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminalPreview_*", "WindowsTerminal.exe"),
|
||||
)
|
||||
}
|
||||
// Legacy/portable layouts occasionally nest the package under LocalAppData.
|
||||
if localAppData = strings.TrimSpace(localAppData); localAppData != "" {
|
||||
out = append(out,
|
||||
filepath.Join(localAppData, "Microsoft", "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe"),
|
||||
filepath.Join(localAppData, "Microsoft", "WindowsApps", "Microsoft.WindowsTerminalPreview_*", "WindowsTerminal.exe"),
|
||||
)
|
||||
}
|
||||
if wtPath = strings.TrimSpace(wtPath); wtPath != "" {
|
||||
out = append(out, wtPath)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// windowsIconCandidate is a filesystem path considered for SHGetFileInfo, with
|
||||
// its observed size. Size 0 is typical for App Execution Alias reparse points.
|
||||
type windowsIconCandidate struct {
|
||||
Path string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// pickWindowsTerminalIconSource chooses an IconSource path for the WT menu row.
|
||||
// Order:
|
||||
// 1. first non-zero-size package/binary path (real WindowsTerminal.exe)
|
||||
// 2. renderableFallback (e.g. powershell.exe) — must beat zero-byte aliases so
|
||||
// SHGetFileInfo is not pointed at an empty stub that yields a blank glyph
|
||||
// 3. zero-size alias / last-known path only when nothing else is available
|
||||
func pickWindowsTerminalIconSource(resolved []windowsIconCandidate, renderableFallback string) string {
|
||||
for _, c := range resolved {
|
||||
if strings.TrimSpace(c.Path) == "" || c.Size <= 0 {
|
||||
continue
|
||||
}
|
||||
return c.Path
|
||||
}
|
||||
if fb := strings.TrimSpace(renderableFallback); fb != "" {
|
||||
return fb
|
||||
}
|
||||
for _, c := range resolved {
|
||||
if path := strings.TrimSpace(c.Path); path != "" {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// windowsConsoleLaunch describes a console opener launch that never goes
|
||||
// through cmd.exe / start (which re-parse working directories as shell text).
|
||||
type windowsConsoleLaunch struct {
|
||||
File string // absolute or PATH-resolved executable
|
||||
Dir string // working directory passed to ShellExecute lpDirectory
|
||||
}
|
||||
|
||||
// planWindowsConsoleLaunch builds a shell-free console launch. File is opened
|
||||
// with ShellExecute("open") and Dir is the process working directory — paths
|
||||
// with spaces or shell metacharacters (& | ( ) ^ %) are never concatenated into
|
||||
// a cmd.exe command line.
|
||||
func planWindowsConsoleLaunch(target, workdir string) windowsConsoleLaunch {
|
||||
return windowsConsoleLaunch{
|
||||
File: strings.TrimSpace(target),
|
||||
Dir: workdir,
|
||||
}
|
||||
}
|
||||
|
||||
// windowsConsoleLaunchIsDirect reports that the plan opens the target binary
|
||||
// itself (via ShellExecute) rather than a cmd.exe /c start wrapper. Command
|
||||
// Prompt as a terminal is still direct: File is cmd.exe and Dir is the
|
||||
// workspace — there is no intermediate shell command line.
|
||||
func windowsConsoleLaunchIsDirect(plan windowsConsoleLaunch) bool {
|
||||
if strings.TrimSpace(plan.File) == "" {
|
||||
return false
|
||||
}
|
||||
// The plan struct has no Args/CommandLine field by design; a regression that
|
||||
// reintroduces cmd /c start would need extra fields or a different shape.
|
||||
return true
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWindowsTerminalIconCandidatePathsPreferPackageBinary(t *testing.T) {
|
||||
// Use fixed Windows-style roots so the assertion is OS-independent; helpers
|
||||
// only join path segments and never touch the filesystem.
|
||||
wtAlias := `C:\Users\x\AppData\Local\Microsoft\WindowsApps\wt.exe`
|
||||
local := `C:\Users\x\AppData\Local`
|
||||
programFiles := `C:\Program Files`
|
||||
|
||||
got := windowsTerminalIconCandidatePaths(wtAlias, local, programFiles)
|
||||
if len(got) < 2 {
|
||||
t.Fatalf("candidates = %v, want package paths before alias", got)
|
||||
}
|
||||
wantFirst := filepath.Join(programFiles, "WindowsApps", "Microsoft.WindowsTerminal_*", "WindowsTerminal.exe")
|
||||
if got[0] != wantFirst {
|
||||
t.Fatalf("first candidate = %q, want %q", got[0], wantFirst)
|
||||
}
|
||||
// Primary hit must be the Store package under Program Files\WindowsApps, not
|
||||
// a nested path under the App Execution Alias directory.
|
||||
if !strings.Contains(got[0], "WindowsApps"+string(filepath.Separator)+"Microsoft.WindowsTerminal_") {
|
||||
t.Fatalf("first candidate = %q, want Microsoft.WindowsTerminal_* under WindowsApps", got[0])
|
||||
}
|
||||
last := got[len(got)-1]
|
||||
if last != wtAlias {
|
||||
t.Fatalf("last candidate = %q, want wt alias %q", last, wtAlias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsConsoleLaunchPlanBypassesCmdAndPreservesMetacharacters(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
target string
|
||||
workdir string
|
||||
}{
|
||||
{name: "spaces", target: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, workdir: `C:\Users\demo\My Project`},
|
||||
{name: "ampersand", target: `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`, workdir: `C:\src\repo&calc`},
|
||||
{name: "pipe_parens", target: `C:\Windows\System32\cmd.exe`, workdir: `C:\src\a|b(c)^%d`},
|
||||
{name: "command_prompt_is_direct_target", target: `C:\Windows\System32\cmd.exe`, workdir: `D:\work`},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan := planWindowsConsoleLaunch(tc.target, tc.workdir)
|
||||
if !windowsConsoleLaunchIsDirect(plan) {
|
||||
t.Fatalf("plan is not a direct ShellExecute open: %+v", plan)
|
||||
}
|
||||
if plan.File != tc.target {
|
||||
t.Fatalf("File = %q, want %q (target binary, not cmd /c start wrapper)", plan.File, tc.target)
|
||||
}
|
||||
if plan.Dir != tc.workdir {
|
||||
t.Fatalf("Dir = %q, want exact workdir (no shell rewriting)", plan.Dir)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsConsoleLaunchIsDirectRejectsEmptyFile(t *testing.T) {
|
||||
if windowsConsoleLaunchIsDirect(windowsConsoleLaunch{}) {
|
||||
t.Fatal("empty plan must not count as direct")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickWindowsTerminalIconSourcePrefersRenderableOverZeroByteAlias(t *testing.T) {
|
||||
wtAlias := `C:\Users\x\AppData\Local\Microsoft\WindowsApps\wt.exe`
|
||||
packageBin := `C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe`
|
||||
powershell := `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe`
|
||||
|
||||
t.Run("nonzero_package_wins", func(t *testing.T) {
|
||||
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
|
||||
{Path: wtAlias, Size: 0},
|
||||
{Path: packageBin, Size: 1_024_000},
|
||||
}, powershell)
|
||||
if got != packageBin {
|
||||
t.Fatalf("got %q, want package binary", got)
|
||||
}
|
||||
})
|
||||
t.Run("renderable_beats_zero_alias", func(t *testing.T) {
|
||||
// Protected WindowsApps often cannot be globbed; only the zero-byte
|
||||
// execution alias remains. PowerShell must win so SHGetFileInfo has a
|
||||
// real PE to extract (blank glyph fix for #6547).
|
||||
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
|
||||
{Path: wtAlias, Size: 0},
|
||||
}, powershell)
|
||||
if got != powershell {
|
||||
t.Fatalf("got %q, want powershell renderable fallback", got)
|
||||
}
|
||||
})
|
||||
t.Run("zero_alias_only_without_fallback", func(t *testing.T) {
|
||||
got := pickWindowsTerminalIconSource([]windowsIconCandidate{
|
||||
{Path: wtAlias, Size: 0},
|
||||
}, "")
|
||||
if got != wtAlias {
|
||||
t.Fatalf("got %q, want alias when no renderable fallback exists", got)
|
||||
}
|
||||
})
|
||||
t.Run("empty", func(t *testing.T) {
|
||||
if got := pickWindowsTerminalIconSource(nil, ""); got != "" {
|
||||
t.Fatalf("got %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWindowsExternalOpenerIconUsesShellIcon(t *testing.T) {
|
||||
explorer := filepath.Join(os.Getenv("WINDIR"), "explorer.exe")
|
||||
if info, err := os.Stat(explorer); err != nil || info.IsDir() {
|
||||
t.Skip("Windows Explorer executable is unavailable")
|
||||
}
|
||||
got := platformExternalOpenerIconDataURL(externalOpenerSpec{IconSource: explorer})
|
||||
if !strings.HasPrefix(got, "data:image/png;base64,") {
|
||||
t.Fatalf("native Explorer icon = %q, want PNG data URL", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsAppPathExecutableEmptyName(t *testing.T) {
|
||||
if got := windowsAppPathExecutable(""); got != "" {
|
||||
t.Fatalf("empty name = %q, want empty", got)
|
||||
}
|
||||
if got := windowsAppPathExecutable(" "); got != "" {
|
||||
t.Fatalf("blank name = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsTerminalIconSourceSkipsZeroByteAliasForPowershell(t *testing.T) {
|
||||
// When only a zero-byte execution alias is visible (package dir unreadable),
|
||||
// IconSource must not stick on the alias — PowerShell is a known renderable
|
||||
// PE that SHGetFileInfo can extract.
|
||||
dir := t.TempDir()
|
||||
alias := filepath.Join(dir, "wt.exe")
|
||||
if err := os.WriteFile(alias, nil, 0o644); err != nil {
|
||||
t.Fatalf("write zero-byte alias: %v", err)
|
||||
}
|
||||
// Point candidate resolution at our temp alias only by calling pick through
|
||||
// the real resolver with overridden env via windowsTerminalIconSource on a
|
||||
// path that will not find package globs, then assert via pick contract.
|
||||
// Direct integration: windowsTerminalIconSource(alias) should return a
|
||||
// non-zero file when powershell is on PATH / System32.
|
||||
got := windowsTerminalIconSource(alias)
|
||||
if got == "" {
|
||||
t.Fatal("icon source empty")
|
||||
}
|
||||
if got == alias {
|
||||
info, err := os.Stat(got)
|
||||
if err != nil {
|
||||
t.Fatalf("stat result: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("icon source stayed on zero-byte alias %q; want renderable fallback", got)
|
||||
}
|
||||
}
|
||||
if info, err := os.Stat(got); err != nil || info.IsDir() || info.Size() == 0 {
|
||||
t.Fatalf("icon source %q is not a non-zero file (err=%v)", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsConsoleLaunchDoesNotInvokeCmdStart(t *testing.T) {
|
||||
// Integration-free: planWindowsConsoleLaunch + ShellExecute path must never
|
||||
// build cmd /c start. The pure helpers test covers special-character dirs;
|
||||
// here we only assert the platform launcher uses that plan shape.
|
||||
ps := firstWindowsExecutable([]string{"powershell.exe"},
|
||||
joinWindowsInstallPath(os.Getenv("WINDIR"), "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))
|
||||
if ps == "" {
|
||||
t.Skip("powershell.exe unavailable")
|
||||
}
|
||||
workdir := filepath.Join(t.TempDir(), "repo&calc")
|
||||
if err := os.MkdirAll(workdir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir workdir: %v", err)
|
||||
}
|
||||
plan := planWindowsConsoleLaunch(ps, workdir)
|
||||
if !windowsConsoleLaunchIsDirect(plan) {
|
||||
t.Fatalf("console plan must be a direct ShellExecute open: %+v", plan)
|
||||
}
|
||||
if plan.File != ps {
|
||||
t.Fatalf("File = %q, want powershell target %q", plan.File, ps)
|
||||
}
|
||||
if plan.Dir != workdir {
|
||||
t.Fatalf("Dir = %q, want %q", plan.Dir, workdir)
|
||||
}
|
||||
// Do not call launchPlatformExternalOpener here: it would open a real
|
||||
// interactive console window in CI. ShellExecute wiring is covered by
|
||||
// openWorkspacePath and compile-time type checks.
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
node_modules
|
||||
# Build output is generated by `pnpm build` / `wails build`; keep only the
|
||||
# placeholder so the Go `//go:embed all:frontend/dist` stays compilable on a
|
||||
# fresh checkout before the first build.
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
|
||||
# Wails-generated bindings (we hand-write a typed bridge instead; regenerate with
|
||||
# `wails generate module` if you prefer the generated ones).
|
||||
wailsjs
|
||||
|
||||
*.local
|
||||
|
||||
# Wails dev install-checksum cache
|
||||
package.json.md5
|
||||
|
||||
# This project uses pnpm (see wails.json `frontend:install`); reject the npm lockfile.
|
||||
package-lock.json
|
||||
|
||||
# Playwright browsers for the real-DOM bench (kept inside the repo; see bench/).
|
||||
.pw-browsers
|
||||
bench/results.json
|
||||
@@ -1,3 +0,0 @@
|
||||
# pnpm 11: don't re-check deps before `pnpm build` and never prompt to purge node_modules headless.
|
||||
confirm-modules-purge=false
|
||||
verify-deps-before-run=false
|
||||
@@ -1 +0,0 @@
|
||||
24
|
||||
@@ -1,83 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
|
||||
? path.join(frontendDir, ".pw-browsers")
|
||||
: process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_APPROVAL_BROWSER_PORT ?? 4620);
|
||||
const url = `http://127.0.0.1:${port}/`;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await new Promise((resolve) => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
resolve((response.statusCode ?? 500) < 500);
|
||||
});
|
||||
request.on("error", () => resolve(false));
|
||||
});
|
||||
if (ready) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error("approval browser preview did not become ready");
|
||||
}
|
||||
|
||||
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
await page.addInitScript(() => {
|
||||
window.__reasonixApprovalAnimationCalls = [];
|
||||
const original = Element.prototype.animate;
|
||||
Element.prototype.animate = function (frames, options) {
|
||||
if (this instanceof HTMLElement && this.querySelector(".prompt-shelf")) {
|
||||
window.__reasonixApprovalAnimationCalls.push({
|
||||
easing: typeof options === "object" && options ? String(options.easing ?? "") : "",
|
||||
});
|
||||
}
|
||||
return original.call(this, frames, options);
|
||||
};
|
||||
});
|
||||
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
const composer = page.locator("#composer-input");
|
||||
await composer.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await composer.fill("/mock-tool-approval");
|
||||
await page.locator(".composer__btn--send").click();
|
||||
|
||||
const action = page.locator(".prompt-shelf__actions .prompt-action").first();
|
||||
await action.waitFor({ state: "visible", timeout: 30_000 });
|
||||
await action.click();
|
||||
await page.locator(".decision-confirm-bar__confirm").click();
|
||||
await page.waitForFunction(() => !document.querySelector(".prompt-shelf--tool-approval"), undefined, { timeout: 10_000 });
|
||||
|
||||
const calls = await page.evaluate(() => window.__reasonixApprovalAnimationCalls ?? []);
|
||||
assert(calls.length === 1, `approval invokes one native Web Animation (${JSON.stringify(calls)})`);
|
||||
assert(calls[0]?.easing === "cubic-bezier(0.8, 0, 0.8, 0.28)", `approval uses the CSS easing contract (${calls[0]?.easing})`);
|
||||
assert(pageErrors.length === 0, `approval interaction completes without page errors (${JSON.stringify(pageErrors)})`);
|
||||
process.stdout.write("\napproval animation browser gate passed\n");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
preview.kill("SIGTERM");
|
||||
}
|
||||
@@ -1,463 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
// Real-DOM performance benchmark for the session-switch/history pipeline
|
||||
// (Phase F). Drives the production build (vite preview) in real Chromium via
|
||||
// Playwright against the ?mock=bench dev-mock fixtures (see makeMockApp in
|
||||
// src/lib/bridge.ts): a tool-dense 38-turn session (~3.2k messages) and a
|
||||
// markdown-heavy 46-turn session (one ~500KiB answer + oversized code block).
|
||||
//
|
||||
// Scenario A: cold open — surface first paint and time-to-interactive
|
||||
// (composer enabled + first transcript slice rendered).
|
||||
// Scenario B: N alternating switches between the two heaviest sessions —
|
||||
// per-switch latency, long-task/event-timing stats, INP-ish input probes, and
|
||||
// a forced-GC retained-heap + cache-budget check against the warmup baseline.
|
||||
//
|
||||
// Usage:
|
||||
// pnpm build # once (the bench reuses dist/; REASONIX_BENCH_BUILD=1 forces a rebuild)
|
||||
// pnpm test:bench
|
||||
//
|
||||
// Gates (env-overridable, defaults = plan values):
|
||||
// REASONIX_BENCH_FIRST_PAINT_P95_MS (100)
|
||||
// REASONIX_BENCH_INTERACTIVE_P95_MS (300)
|
||||
// REASONIX_BENCH_INP_P95_MS (200)
|
||||
// REASONIX_BENCH_LONGTASK_P95_MS (50)
|
||||
// REASONIX_BENCH_LONGTASK_MAX_MS (500)
|
||||
// REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS (3000)
|
||||
// REASONIX_BENCH_HEAP_GROWTH_MIB (20)
|
||||
// REASONIX_BENCH_SWITCHES (100)
|
||||
// REASONIX_BENCH_COLD_RUNS (5)
|
||||
// REASONIX_BENCH_WARMUP (6)
|
||||
// REASONIX_BENCH_PORT (4617)
|
||||
//
|
||||
// Exit code is 0 when every gate passes, 1 otherwise. Results are written to
|
||||
// bench/results.json and summarized on stdout.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import http from "node:http";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
// Keep the browser download inside the repo when the caller did not pin one.
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
|
||||
? path.join(frontendDir, ".pw-browsers")
|
||||
: process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
|
||||
const { chromium } = await import("playwright");
|
||||
|
||||
function numEnv(name, fallback) {
|
||||
const raw = process.env[name];
|
||||
const value = raw === undefined ? NaN : Number(raw);
|
||||
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||
}
|
||||
|
||||
const GATES = {
|
||||
firstPaintP95Ms: numEnv("REASONIX_BENCH_FIRST_PAINT_P95_MS", 100),
|
||||
interactiveP95Ms: numEnv("REASONIX_BENCH_INTERACTIVE_P95_MS", 300),
|
||||
inpP95Ms: numEnv("REASONIX_BENCH_INP_P95_MS", 200),
|
||||
longTaskP95Ms: numEnv("REASONIX_BENCH_LONGTASK_P95_MS", 50),
|
||||
longTaskMaxMs: numEnv("REASONIX_BENCH_LONGTASK_MAX_MS", 500),
|
||||
markdownParseMaxMs: numEnv("REASONIX_BENCH_MARKDOWN_PARSE_MAX_MS", 3000),
|
||||
heapGrowthMiB: numEnv("REASONIX_BENCH_HEAP_GROWTH_MIB", 20),
|
||||
};
|
||||
const SWITCHES = Math.round(numEnv("REASONIX_BENCH_SWITCHES", 100));
|
||||
const COLD_RUNS = Math.round(numEnv("REASONIX_BENCH_COLD_RUNS", 5));
|
||||
const WARMUP_SWITCHES = Math.round(numEnv("REASONIX_BENCH_WARMUP", 6));
|
||||
const PORT = Math.round(numEnv("REASONIX_BENCH_PORT", 4617));
|
||||
|
||||
// Cache budgets mirrored from transcriptStore.ts defaults (asserted via the
|
||||
// __reasonixPerf debug hook, which reports the store's own numbers).
|
||||
const BODY_BUDGET_BYTES = 32 << 20;
|
||||
const MARKDOWN_BUDGET_BYTES = 16 << 20;
|
||||
const MAX_RESIDENT_SESSIONS = 3;
|
||||
|
||||
const PAGE_URL = `http://127.0.0.1:${PORT}/?mock=bench&bench=1`;
|
||||
// Single-surface (workbench) layout: switches are sidebar topic clicks driving
|
||||
// the ticketed StartTopicActivation flow. Active state is the --active class
|
||||
// on the topic row; the transcript marker text proves the target session's
|
||||
// first slice rendered.
|
||||
const TAB = {
|
||||
// Markers must be in the STUCK-TO-BOTTOM viewport (the virtual list only
|
||||
// mounts overscan rows): the tools session ends on its last read_file card,
|
||||
// the markdown session on the oversized code block (worker-parsed).
|
||||
markdown: { label: "bench:markdown-46t", marker: "generated migration" },
|
||||
tools: { label: "bench:tools-38t", marker: "pkg-41/mod.go" },
|
||||
};
|
||||
|
||||
function percentile(values, p) {
|
||||
if (values.length === 0) return undefined;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
|
||||
return sorted[Math.max(0, index)];
|
||||
}
|
||||
|
||||
function statsOf(values) {
|
||||
const nums = values.filter((v) => Number.isFinite(v));
|
||||
return {
|
||||
n: nums.length,
|
||||
p50: percentile(nums, 50),
|
||||
p95: percentile(nums, 95),
|
||||
max: nums.length ? Math.max(...nums) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForServer(url, timeoutMs = 30_000) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
for (;;) {
|
||||
const ok = await new Promise((resolve) => {
|
||||
const req = http.get(url, (res) => {
|
||||
res.resume();
|
||||
resolve(res.statusCode !== undefined && res.statusCode < 500);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
resolve(false);
|
||||
});
|
||||
});
|
||||
if (ok) return;
|
||||
if (Date.now() > deadline) throw new Error(`preview server did not start at ${url}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureBuild() {
|
||||
const distIndex = path.join(frontendDir, "dist", "index.html");
|
||||
const force = process.env.REASONIX_BENCH_BUILD === "1";
|
||||
if (existsSync(distIndex) && !force) return;
|
||||
console.log("[bench] building frontend (vite build)…");
|
||||
await new Promise((resolve, reject) => {
|
||||
const child = spawn("pnpm", ["build"], { cwd: frontendDir, stdio: "inherit" });
|
||||
child.on("exit", (code) => (code === 0 ? resolve() : reject(new Error(`pnpm build exited ${code}`))));
|
||||
});
|
||||
}
|
||||
|
||||
async function startPreview() {
|
||||
const child = spawn("pnpm", ["exec", "vite", "preview", "--port", String(PORT), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
const message = chunk.toString();
|
||||
stderr += message;
|
||||
process.stderr.write(`[preview] ${message}`);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
let stdout = "";
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
child.kill();
|
||||
reject(new Error(`preview server did not announce readiness on port ${PORT}`));
|
||||
}, 30_000);
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
child.stdout.off("data", onStdout);
|
||||
child.off("exit", onExit);
|
||||
};
|
||||
const onStdout = (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
if (!/\bLocal:\s+http/.test(stdout)) return;
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
const onExit = (code, signal) => {
|
||||
cleanup();
|
||||
const detail = stderr.trim();
|
||||
reject(new Error(
|
||||
`preview server exited before readiness (code ${code ?? "null"}, signal ${signal ?? "none"})${detail ? `: ${detail}` : ""}`,
|
||||
));
|
||||
};
|
||||
child.stdout.on("data", onStdout);
|
||||
child.once("exit", onExit);
|
||||
});
|
||||
await waitForServer(`http://127.0.0.1:${PORT}/`);
|
||||
return child;
|
||||
}
|
||||
|
||||
// Installed before any app script: long-task, event-timing (INP-ish) and paint
|
||||
// collectors. All bounded by the scenario durations (a few minutes).
|
||||
const COLLECTOR_INIT = () => {
|
||||
const metrics = { longTasks: [], events: [], paints: [] };
|
||||
window.__benchMetrics = metrics;
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) metrics.longTasks.push({ start: entry.startTime, duration: entry.duration });
|
||||
}).observe({ type: "longtask", buffered: true });
|
||||
} catch { /* longtask unsupported */ }
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) metrics.events.push({ name: entry.name, duration: entry.duration });
|
||||
}).observe({ type: "event", durationThreshold: 16, buffered: true });
|
||||
} catch { /* event timing unsupported */ }
|
||||
try {
|
||||
new PerformanceObserver((list) => {
|
||||
for (const entry of list.getEntries()) metrics.paints.push({ name: entry.name, start: entry.startTime });
|
||||
}).observe({ type: "paint", buffered: true });
|
||||
} catch { /* paint timing unsupported */ }
|
||||
};
|
||||
|
||||
async function forceGcAndHeap(cdp, page) {
|
||||
await cdp.send("HeapProfiler.enable").catch(() => {});
|
||||
await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
await cdp.send("HeapProfiler.collectGarbage").catch(() => {});
|
||||
await page.waitForTimeout(150);
|
||||
return page.evaluate(() => {
|
||||
const memory = performance.memory;
|
||||
return {
|
||||
usedJSHeapBytes: memory ? memory.usedJSHeapSize : undefined,
|
||||
totalJSHeapBytes: memory ? memory.totalJSHeapSize : undefined,
|
||||
domNodes: document.getElementsByTagName("*").length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const INTERACTIVE_FN = () => {
|
||||
const input = document.querySelector("textarea.composer__input");
|
||||
const inputReady = Boolean(input && !input.disabled);
|
||||
const rows = document.querySelectorAll(".transcript__row").length;
|
||||
return inputReady && rows > 0;
|
||||
};
|
||||
|
||||
async function coldOpenOnce(browser) {
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(COLLECTOR_INIT);
|
||||
await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
|
||||
const result = await page.evaluate(() => {
|
||||
const paints = (window.__benchMetrics?.paints ?? []);
|
||||
const fcp = paints.find((p) => p.name === "first-contentful-paint") ?? paints.find((p) => p.name === "first-paint");
|
||||
return { firstPaintMs: fcp ? fcp.start : undefined, interactiveMs: performance.now() };
|
||||
});
|
||||
await context.close();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function waitForSessionVisible(page, tab, timeoutMs = 15_000) {
|
||||
await page.waitForFunction(
|
||||
({ label, marker }) => {
|
||||
const active = document.querySelector(".project-tree__topic--active .project-tree__topic-label");
|
||||
if (!active || !active.textContent?.includes(label)) return false;
|
||||
const transcript = document.querySelector(".transcript");
|
||||
return Boolean(transcript && transcript.textContent?.includes(marker));
|
||||
},
|
||||
{ label: tab.label, marker: tab.marker },
|
||||
{ timeout: timeoutMs, polling: "raf" },
|
||||
);
|
||||
}
|
||||
|
||||
async function switchTo(page, tab) {
|
||||
const startedAt = await page.evaluate(() => performance.now());
|
||||
await page.click(`.project-tree__topic-main:has-text("${tab.label}")`);
|
||||
await waitForSessionVisible(page, tab);
|
||||
const settledAt = await page.evaluate(() => performance.now());
|
||||
return settledAt - startedAt;
|
||||
}
|
||||
|
||||
// Let in-flight worker parses finish so the parsed-markdown cache is populated
|
||||
// and exercised (a fast switch-away cancels pending parses by design). Runs
|
||||
// AFTER the switch-latency measurement so click→first-render stays pure.
|
||||
async function settleMarkdownWorker(page, timeoutMs = 10_000) {
|
||||
await page
|
||||
.waitForFunction(() => (window.__reasonixPerf?.stats()?.markdownWorker?.pending ?? 0) === 0, undefined, {
|
||||
timeout: timeoutMs,
|
||||
polling: 100,
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// Progressive history blocks intentionally mount in idle slices after parsing.
|
||||
// Stabilize that deferred work only around heap/DOM snapshots; waiting on every
|
||||
// switch would change the interaction workload the benchmark is measuring.
|
||||
async function settleMarkdownMounts(page, timeoutMs = 10_000) {
|
||||
try {
|
||||
await page.waitForFunction(() => (
|
||||
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].every((element) => (
|
||||
element.getAttribute("data-markdown-visible-blocks") === element.getAttribute("data-markdown-blocks")
|
||||
))
|
||||
), undefined, { timeout: timeoutMs, polling: 100 });
|
||||
} catch (error) {
|
||||
const counts = await page.evaluate(() => (
|
||||
[...document.querySelectorAll(".transcript [data-markdown-blocks]")].map((element) => ({
|
||||
total: element.getAttribute("data-markdown-blocks"),
|
||||
visible: element.getAttribute("data-markdown-visible-blocks"),
|
||||
}))
|
||||
));
|
||||
throw new Error(`active Markdown mounts did not settle: ${JSON.stringify(counts)}`, { cause: error });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureBuild();
|
||||
const preview = await startPreview();
|
||||
const report = {
|
||||
startedAt: new Date().toISOString(),
|
||||
url: PAGE_URL,
|
||||
gates: GATES,
|
||||
switches: SWITCHES,
|
||||
coldRuns: COLD_RUNS,
|
||||
checks: [],
|
||||
scenarioA: {},
|
||||
scenarioB: {},
|
||||
};
|
||||
const check = (name, value, gate, pass, unit = "ms") => {
|
||||
report.checks.push({ name, value, gate, pass, unit });
|
||||
console.log(` ${pass ? "PASS" : "FAIL"} ${name}: ${typeof value === "number" ? value.toFixed(1) : value}${unit} (gate ${gate}${unit})`);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ["--enable-precise-memory-info", "--disable-dev-shm-usage"],
|
||||
});
|
||||
try {
|
||||
// ── Scenario A: cold open ──────────────────────────────────────────────
|
||||
console.log(`[bench] scenario A: ${COLD_RUNS} cold opens of the markdown-heavy session…`);
|
||||
const coldFirstPaint = [];
|
||||
const coldInteractive = [];
|
||||
for (let i = 0; i < COLD_RUNS; i += 1) {
|
||||
const { firstPaintMs, interactiveMs } = await coldOpenOnce(browser);
|
||||
if (firstPaintMs !== undefined) coldFirstPaint.push(firstPaintMs);
|
||||
coldInteractive.push(interactiveMs);
|
||||
}
|
||||
report.scenarioA = { firstPaintMs: statsOf(coldFirstPaint), interactiveMs: statsOf(coldInteractive) };
|
||||
check("A first-paint P95", report.scenarioA.firstPaintMs.p95, GATES.firstPaintP95Ms, report.scenarioA.firstPaintMs.p95 <= GATES.firstPaintP95Ms);
|
||||
check("A interactive P95", report.scenarioA.interactiveMs.p95, GATES.interactiveP95Ms, report.scenarioA.interactiveMs.p95 <= GATES.interactiveP95Ms);
|
||||
|
||||
// ── Scenario B: alternating heaviest-session switches ──────────────────
|
||||
console.log(`[bench] scenario B: ${SWITCHES} alternating switches (warmup ${WARMUP_SWITCHES})…`);
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
await page.addInitScript(COLLECTOR_INIT);
|
||||
await page.goto(PAGE_URL, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(INTERACTIVE_FN, undefined, { timeout: 30_000, polling: "raf" });
|
||||
await page.waitForFunction(() => Boolean(window.__reasonixPerf), { timeout: 10_000 });
|
||||
const cdp = await context.newCDPSession(page);
|
||||
|
||||
// Warmup: fill the LRU with both sessions, then settle back on the
|
||||
// markdown tab so the baseline and the final reading show the same view.
|
||||
for (let i = 0; i < WARMUP_SWITCHES; i += 1) {
|
||||
const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
|
||||
await switchTo(page, target);
|
||||
if (target === TAB.markdown) await settleMarkdownWorker(page);
|
||||
}
|
||||
await settleMarkdownMounts(page);
|
||||
const baseline = await forceGcAndHeap(cdp, page);
|
||||
const baselineStats = await page.evaluate(() => window.__reasonixPerf.stats());
|
||||
await page.evaluate(() => {
|
||||
window.__benchMetrics.longTasks.length = 0;
|
||||
window.__benchMetrics.events.length = 0;
|
||||
window.__reasonixPerf.reset();
|
||||
});
|
||||
|
||||
const switchLatencies = [];
|
||||
for (let i = 0; i < SWITCHES; i += 1) {
|
||||
// First switch leaves the markdown tab (warmup ended there); even count
|
||||
// lands back on markdown so the final heap/DOM reading compares like for
|
||||
// like with the baseline.
|
||||
const target = i % 2 === 0 ? TAB.tools : TAB.markdown;
|
||||
switchLatencies.push(await switchTo(page, target));
|
||||
if (target === TAB.markdown) await settleMarkdownWorker(page);
|
||||
if ((i + 1) % 10 === 0) {
|
||||
// INP-ish probe: real key events against the composer while the
|
||||
// pipeline is warm; event-timing entries capture the latency.
|
||||
const composer = page.locator("textarea.composer__input");
|
||||
await composer.click();
|
||||
await page.keyboard.type("x", { delay: 5 });
|
||||
await page.keyboard.press("Backspace");
|
||||
}
|
||||
}
|
||||
|
||||
await settleMarkdownMounts(page);
|
||||
|
||||
// Freeze interaction metrics before the explicit HeapProfiler GC below.
|
||||
// The GC is part of retained-heap measurement, not the user switching
|
||||
// workflow, and can itself create a >50ms task on a loaded test host.
|
||||
const { finalStats, activations, benchMetrics } = await page.evaluate(() => ({
|
||||
finalStats: window.__reasonixPerf.stats(),
|
||||
activations: window.__reasonixPerf.activations(),
|
||||
benchMetrics: {
|
||||
longTasks: window.__benchMetrics.longTasks.map((t) => t.duration),
|
||||
events: window.__benchMetrics.events.map((e) => e.duration),
|
||||
},
|
||||
}));
|
||||
const final = await forceGcAndHeap(cdp, page);
|
||||
await context.close();
|
||||
|
||||
const switchStats = statsOf(switchLatencies);
|
||||
const readyStats = statsOf(
|
||||
activations.filter((a) => a.outcome === "ready" && a.settledAtMs !== undefined).map((a) => a.settledAtMs - a.requestedAtMs),
|
||||
);
|
||||
const longTaskStats = statsOf(benchMetrics.longTasks);
|
||||
const eventStats = statsOf(benchMetrics.events);
|
||||
const heapGrowthBytes = (final.usedJSHeapBytes ?? 0) - (baseline.usedJSHeapBytes ?? 0);
|
||||
const heapGrowthMiB = heapGrowthBytes / (1 << 20);
|
||||
const cache = finalStats?.transcriptCache ?? {};
|
||||
const worker = finalStats?.markdownWorker ?? {};
|
||||
|
||||
report.scenarioB = {
|
||||
switchMs: switchStats,
|
||||
activationReadyMs: readyStats,
|
||||
longTasksMs: longTaskStats,
|
||||
inputEventsMs: eventStats,
|
||||
heap: {
|
||||
baselineUsedMiB: (baseline.usedJSHeapBytes ?? 0) / (1 << 20),
|
||||
finalUsedMiB: (final.usedJSHeapBytes ?? 0) / (1 << 20),
|
||||
growthMiB: heapGrowthMiB,
|
||||
},
|
||||
domNodes: { baseline: baseline.domNodes, final: final.domNodes },
|
||||
transcriptCache: cache,
|
||||
markdownWorker: worker,
|
||||
baselineCache: baselineStats?.transcriptCache,
|
||||
};
|
||||
|
||||
check("B switch latency P95 (click→target session rendered)", switchStats.p95, GATES.interactiveP95Ms, switchStats.p95 <= GATES.interactiveP95Ms);
|
||||
if (readyStats.n > 0) {
|
||||
check("B activation ready P95", readyStats.p95, GATES.interactiveP95Ms, readyStats.p95 <= GATES.interactiveP95Ms);
|
||||
}
|
||||
check("B input-event P95 (INP-ish)", eventStats.p95 ?? 0, GATES.inpP95Ms, (eventStats.p95 ?? 0) <= GATES.inpP95Ms);
|
||||
check("B long-task P95", longTaskStats.p95 ?? 0, GATES.longTaskP95Ms, (longTaskStats.p95 ?? 0) <= GATES.longTaskP95Ms);
|
||||
check("B long-task max", longTaskStats.max ?? 0, GATES.longTaskMaxMs, (longTaskStats.max ?? 0) <= GATES.longTaskMaxMs);
|
||||
check("B markdown Worker completed parses (minimum)", worker.completed ?? 0, 1, (worker.completed ?? 0) >= 1, "");
|
||||
check(
|
||||
"B markdown Worker max parse",
|
||||
worker.maxParseMs ?? 0,
|
||||
GATES.markdownParseMaxMs,
|
||||
(worker.completed ?? 0) >= 1 && (worker.maxParseMs ?? Infinity) <= GATES.markdownParseMaxMs,
|
||||
);
|
||||
check("B retained heap growth", heapGrowthMiB, GATES.heapGrowthMiB, heapGrowthMiB <= GATES.heapGrowthMiB, "MiB");
|
||||
check(
|
||||
"B cache: body bytes within budget",
|
||||
(cache.bodyBytes ?? 0) / (1 << 20),
|
||||
BODY_BUDGET_BYTES / (1 << 20),
|
||||
(cache.bodyBytes ?? 0) <= BODY_BUDGET_BYTES,
|
||||
"MiB",
|
||||
);
|
||||
check(
|
||||
"B cache: markdown bytes within budget",
|
||||
(cache.markdownBytes ?? 0) / (1 << 20),
|
||||
MARKDOWN_BUDGET_BYTES / (1 << 20),
|
||||
(cache.markdownBytes ?? 0) <= MARKDOWN_BUDGET_BYTES,
|
||||
"MiB",
|
||||
);
|
||||
check("B cache: resident sessions", cache.residentSessions ?? 0, MAX_RESIDENT_SESSIONS, (cache.residentSessions ?? 0) <= MAX_RESIDENT_SESSIONS, "");
|
||||
const domGrowthPct = baseline.domNodes > 0 ? ((final.domNodes - baseline.domNodes) / baseline.domNodes) * 100 : 0;
|
||||
check("B DOM node growth vs warmup baseline", domGrowthPct, 10, domGrowthPct <= 10, "%");
|
||||
} finally {
|
||||
await browser.close();
|
||||
preview.kill();
|
||||
}
|
||||
|
||||
report.finishedAt = new Date().toISOString();
|
||||
const failed = report.checks.filter((c) => !c.pass);
|
||||
report.verdict = failed.length === 0 ? "PASS" : "FAIL";
|
||||
writeFileSync(path.join(frontendDir, "bench", "results.json"), JSON.stringify(report, null, 2));
|
||||
console.log(`\n[bench] verdict: ${report.verdict} (${failed.length} gate${failed.length === 1 ? "" : "s"} failed) — results in bench/results.json`);
|
||||
if (failed.length > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`[bench] fatal: ${err instanceof Error ? err.stack ?? err.message : err}`);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
@@ -1,249 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
|
||||
? path.join(frontendDir, ".pw-browsers")
|
||||
: process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_SCROLL_PORT ?? 4619);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await new Promise((resolve) => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
resolve((response.statusCode ?? 500) < 500);
|
||||
});
|
||||
request.on("error", () => resolve(false));
|
||||
});
|
||||
if (ready) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error("transcript scroll preview did not become ready");
|
||||
}
|
||||
|
||||
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
...(process.env.PLAYWRIGHT_EXECUTABLE_PATH ? { executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH } : {}),
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go"), undefined, { timeout: 30_000 });
|
||||
|
||||
const transcript = page.locator(".transcript");
|
||||
const box = await transcript.boundingBox();
|
||||
assert(box != null, "bench exposes the Virtuoso transcript viewport");
|
||||
assert(await page.locator('[data-virtuoso-scroller="true"]').count() === 1, "Transcript is backed by React Virtuoso");
|
||||
|
||||
// Start away from either edge and record a visible stable row. Growing an
|
||||
// already-mounted row above it reproduces async Markdown/tool hydration.
|
||||
const beforeGrowth = await transcript.evaluate((element) => {
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2);
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const anchor = visible.find((row) => row.getBoundingClientRect().top >= viewport.top) ?? visible[0];
|
||||
const above = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom <= anchor?.getBoundingClientRect().top)
|
||||
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
|
||||
return {
|
||||
top: element.scrollTop,
|
||||
anchorKey: anchor?.dataset.rowKey ?? null,
|
||||
anchorOffset: anchor ? anchor.getBoundingClientRect().top - viewport.top : null,
|
||||
grownKey: above?.dataset.rowKey ?? null,
|
||||
};
|
||||
});
|
||||
assert(beforeGrowth.anchorKey && beforeGrowth.grownKey, "bench exposes a visible anchor and mounted dynamic row above it");
|
||||
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
|
||||
await transcript.evaluate((element) => {
|
||||
const initialTop = element.scrollTop;
|
||||
let previousTop = initialTop;
|
||||
let stableFrames = 0;
|
||||
element.dataset.benchWheelSettled = "false";
|
||||
const sample = () => {
|
||||
const currentTop = element.scrollTop;
|
||||
const moved = Math.abs(currentTop - initialTop) > 1;
|
||||
stableFrames = moved && Math.abs(currentTop - previousTop) <= 0.5 ? stableFrames + 1 : 0;
|
||||
previousTop = currentTop;
|
||||
if (stableFrames >= 2) {
|
||||
element.dataset.benchWheelSettled = "true";
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(sample);
|
||||
};
|
||||
requestAnimationFrame(sample);
|
||||
});
|
||||
await page.mouse.wheel(0, -360);
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.benchWheelSettled === "true");
|
||||
const gestureStart = await transcript.evaluate((element) => element.scrollTop);
|
||||
await transcript.evaluate((element) => {
|
||||
const viewport = element.getBoundingClientRect();
|
||||
const rows = [...element.querySelectorAll(".transcript__row")];
|
||||
const visible = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom > viewport.top && row.getBoundingClientRect().top < viewport.bottom)
|
||||
.sort((left, right) => left.getBoundingClientRect().top - right.getBoundingClientRect().top);
|
||||
const above = rows
|
||||
.filter((row) => row.getBoundingClientRect().bottom <= viewport.top)
|
||||
.sort((left, right) => right.getBoundingClientRect().bottom - left.getBoundingClientRect().bottom)[0];
|
||||
if (above instanceof HTMLElement) above.style.paddingBottom = `${Number.parseFloat(above.style.paddingBottom || "0") + 1200}px`;
|
||||
window.__reasonixScrollSamples = [];
|
||||
const sample = () => {
|
||||
const currentRows = [...element.querySelectorAll(".transcript__row")];
|
||||
const rect = element.getBoundingClientRect();
|
||||
const occupied = currentRows.some((row) => {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
|
||||
});
|
||||
window.__reasonixScrollSamples.push({ top: element.scrollTop, occupied });
|
||||
};
|
||||
element.addEventListener("scroll", sample, { passive: true });
|
||||
sample();
|
||||
});
|
||||
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
const afterGrowth = await transcript.evaluate((element) => ({
|
||||
top: element.scrollTop,
|
||||
samples: window.__reasonixScrollSamples ?? [],
|
||||
}));
|
||||
assert(afterGrowth.top >= gestureStart - 2, `dynamic measurement never reverses an upward gesture into a multi-screen jump (${gestureStart} → ${afterGrowth.top})`);
|
||||
assert(afterGrowth.samples.every((sample) => sample.occupied), "dynamic measurement never exposes a blank transcript viewport");
|
||||
|
||||
// Rapid direction changes are the exact user report. Sample every frame and
|
||||
// require that Virtuoso always maintains mounted coverage.
|
||||
for (const delta of [-700, -700, 480, -600, 520, -460]) {
|
||||
await page.mouse.wheel(0, delta);
|
||||
await page.waitForTimeout(24);
|
||||
}
|
||||
const rapid = await transcript.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const visible = [...element.querySelectorAll(".transcript__row")].filter((row) => {
|
||||
const rowRect = row.getBoundingClientRect();
|
||||
return rowRect.bottom > rect.top && rowRect.top < rect.bottom;
|
||||
});
|
||||
return { visible: visible.length, top: element.scrollTop, max: element.scrollHeight - element.clientHeight };
|
||||
});
|
||||
assert(rapid.visible > 0, `rapid bidirectional scrolling leaves rendered coverage (${rapid.visible} visible rows)`);
|
||||
assert(rapid.top >= 0 && rapid.top <= rapid.max + 1, `rapid scrolling stays within the native scroll range (${rapid.top}/${rapid.max})`);
|
||||
|
||||
// A native scrollbar thumb drag owns the browser's scroll range. Keep
|
||||
// Virtuoso's estimated size tree fixed until pointer release so newly
|
||||
// visited variable-height rows cannot resize the thumb under the pointer.
|
||||
// This is deliberately pointer-gutter-specific; the wheel assertions above
|
||||
// continue to exercise ordinary chat-content scrolling and live measuring.
|
||||
await transcript.evaluate((element) => {
|
||||
element.scrollTop = 0;
|
||||
element.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForFunction(() => document.querySelector(".transcript__row"));
|
||||
const nativeThumbProbe = await transcript.evaluate((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const scaleX = rect.width / element.offsetWidth;
|
||||
const contentRight = rect.left + (element.clientLeft + element.clientWidth) * scaleX;
|
||||
const row = element.querySelector(".transcript__row");
|
||||
if (!(row instanceof HTMLElement)) return null;
|
||||
row.dataset.nativeScrollbarProbe = "true";
|
||||
return {
|
||||
x: Math.min(rect.right - 1, contentRight + Math.max(1, (rect.right - contentRight) / 2)),
|
||||
y: rect.top + 5,
|
||||
knownSize: Number.parseFloat(row.dataset.knownSize || "0"),
|
||||
gutter: rect.right - contentRight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(nativeThumbProbe && nativeThumbProbe.gutter > 1, `workbench exposes a native scrollbar gutter (${nativeThumbProbe?.gutter ?? 0}px)`);
|
||||
assert(nativeThumbProbe.knownSize > 0, `native scrollbar probe starts from a measured row (${nativeThumbProbe.knownSize}px)`);
|
||||
await page.mouse.move(nativeThumbProbe.x, nativeThumbProbe.y);
|
||||
await page.mouse.down();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.dataset.nativeScrollbarDrag === "true");
|
||||
await page.waitForFunction(
|
||||
(knownSize) => document.querySelector('[data-native-scrollbar-probe="true"]')?.style.height === `${knownSize}px`,
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
const content = row?.firstElementChild;
|
||||
if (content instanceof HTMLElement) content.style.paddingBottom = `${Number.parseFloat(content.style.paddingBottom || "0") + 900}px`;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const duringNativeThumbDrag = await transcript.evaluate((element) => {
|
||||
const row = element.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return {
|
||||
knownSize: row instanceof HTMLElement ? Number.parseFloat(row.dataset.knownSize || "0") : 0,
|
||||
fixedHeight: row instanceof HTMLElement ? row.style.height : "",
|
||||
rowHeight: row instanceof HTMLElement ? row.getBoundingClientRect().height : 0,
|
||||
listHeight: element.querySelector('[data-testid="virtuoso-item-list"]')?.getBoundingClientRect().height ?? 0,
|
||||
scrollHeight: element.scrollHeight,
|
||||
};
|
||||
});
|
||||
assert(duringNativeThumbDrag.knownSize === nativeThumbProbe.knownSize, `native thumb drag freezes new row measurements (${duringNativeThumbDrag.knownSize}px)`);
|
||||
assert(duringNativeThumbDrag.fixedHeight === `${nativeThumbProbe.knownSize}px`, `native thumb drag fixes mounted row layout (${duringNativeThumbDrag.fixedHeight})`);
|
||||
assert(Math.abs(duringNativeThumbDrag.scrollHeight - nativeThumbProbe.scrollHeight) <= 8, `native thumb drag keeps the physical scroll range stable (${nativeThumbProbe.scrollHeight} → ${duringNativeThumbDrag.scrollHeight}; row ${duringNativeThumbDrag.rowHeight}; list ${duringNativeThumbDrag.listHeight})`);
|
||||
await page.mouse.up();
|
||||
await page.waitForFunction(
|
||||
(knownSize) => {
|
||||
const transcriptElement = document.querySelector(".transcript");
|
||||
const row = document.querySelector('[data-native-scrollbar-probe="true"]');
|
||||
return transcriptElement?.dataset.nativeScrollbarDrag !== "true"
|
||||
&& row instanceof HTMLElement
|
||||
&& Number.parseFloat(row.dataset.knownSize || "0") > knownSize + 800;
|
||||
},
|
||||
nativeThumbProbe.knownSize,
|
||||
);
|
||||
assert(true, "native thumb release resumes real row measurement");
|
||||
|
||||
// Explicit bottom owns the tail. Subsequent async growth must use Virtuoso's
|
||||
// autoscroll API and remain at the physical bottom without Reasonix scrollTop
|
||||
// correction loops.
|
||||
const jumpBottom = page.locator(".transcript__jump-bottom");
|
||||
await transcript.evaluate((element) => {
|
||||
element.scrollTop = Math.max(0, element.scrollHeight - element.clientHeight * 2);
|
||||
});
|
||||
await jumpBottom.waitFor({ state: "visible" });
|
||||
await jumpBottom.click();
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element
|
||||
&& element.dataset.scrollMode === "tail-follow"
|
||||
&& element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
await transcript.evaluate((element) => {
|
||||
const tail = [...element.querySelectorAll(".transcript__row")].at(-1);
|
||||
if (tail instanceof HTMLElement) tail.style.paddingBottom = `${Number.parseFloat(tail.style.paddingBottom || "0") + 320}px`;
|
||||
});
|
||||
await page.waitForFunction(() => {
|
||||
const element = document.querySelector(".transcript");
|
||||
return element && element.scrollHeight - element.scrollTop - element.clientHeight <= 1;
|
||||
});
|
||||
assert(true, "pinned dynamic tail growth remains at the physical bottom");
|
||||
|
||||
process.stdout.write("\ntranscript scroll stability browser gate passed\n");
|
||||
} finally {
|
||||
await browser?.close();
|
||||
preview.kill("SIGTERM");
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = !process.env.PLAYWRIGHT_BROWSERS_PATH || process.env.PLAYWRIGHT_BROWSERS_PATH === ".pw-browsers"
|
||||
? path.join(frontendDir, ".pw-browsers")
|
||||
: process.env.PLAYWRIGHT_BROWSERS_PATH;
|
||||
const { chromium } = await import("playwright");
|
||||
const port = Number(process.env.REASONIX_TRANSCRIPT_BROWSER_PORT ?? 4618);
|
||||
const url = `http://127.0.0.1:${port}/?mock=bench&bench=1`;
|
||||
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
process.stdout.write(` PASS ${message}\n`);
|
||||
}
|
||||
|
||||
async function waitForServer() {
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
const ready = await new Promise((resolve) => {
|
||||
const request = http.get(url, (response) => {
|
||||
response.resume();
|
||||
resolve((response.statusCode ?? 500) < 500);
|
||||
});
|
||||
request.on("error", () => resolve(false));
|
||||
});
|
||||
if (ready) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
throw new Error("transcript browser preview did not become ready");
|
||||
}
|
||||
|
||||
const preview = spawn("pnpm", ["exec", "vite", "preview", "--port", String(port), "--strictPort", "--host", "127.0.0.1"], {
|
||||
cwd: frontendDir,
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
let browser;
|
||||
try {
|
||||
await waitForServer();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
const cdp = await page.context().newCDPSession(page);
|
||||
await cdp.send("Performance.enable");
|
||||
const retainedHeap = async () => {
|
||||
await cdp.send("HeapProfiler.collectGarbage");
|
||||
await page.waitForTimeout(100);
|
||||
const metrics = await cdp.send("Performance.getMetrics");
|
||||
return metrics.metrics.find((metric) => metric.name === "JSHeapUsedSize")?.value ?? 0;
|
||||
};
|
||||
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript__row").length > 4, undefined, { timeout: 30_000 });
|
||||
await page.waitForFunction(() => !document.querySelector(".startup-splash"), undefined, { timeout: 30_000 });
|
||||
await page.click('.project-tree__topic-main:has-text("bench:tools-38t")');
|
||||
await page.waitForFunction(() => (
|
||||
document.querySelector(".project-tree__topic--active .project-tree__topic-label")?.textContent?.includes("bench:tools-38t")
|
||||
&& document.querySelector(".transcript")?.textContent?.includes("pkg-41/mod.go")
|
||||
), undefined, { timeout: 30_000 });
|
||||
await page.waitForTimeout(300);
|
||||
for (let pageIndex = 0; pageIndex < 8; pageIndex += 1) {
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (transcript) transcript.scrollTop = 0;
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
const older = page.locator(".transcript__older");
|
||||
if (await older.count() === 0) break;
|
||||
await older.click();
|
||||
await page.waitForTimeout(350);
|
||||
}
|
||||
const jumpBottom = page.locator(".transcript__jump-bottom");
|
||||
if (await jumpBottom.count()) await jumpBottom.click();
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
return transcript && transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight <= 1;
|
||||
}, undefined, { timeout: 30_000 });
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
const visibleSelectable = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return false;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const visible = [...transcript.querySelectorAll("[data-transcript-selectable]")].some((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!visible) transcript.scrollTop -= transcript.clientHeight;
|
||||
return visible;
|
||||
});
|
||||
if (visibleSelectable) break;
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
|
||||
const baselineRows = await page.locator(".transcript__row").count();
|
||||
const points = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const candidates = [...transcript.querySelectorAll("[data-transcript-selectable]")]
|
||||
.map((element) => ({
|
||||
element,
|
||||
turn: Number(element.textContent?.match(/\bbench turn (\d+):/)?.[1] ?? -1),
|
||||
rect: element.getBoundingClientRect(),
|
||||
}))
|
||||
.filter(({ turn, rect }) => turn >= 0 && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom)
|
||||
.sort((left, right) => right.turn - left.turn);
|
||||
const candidate = candidates[0];
|
||||
const textRects = candidate ? (() => {
|
||||
const walker = document.createTreeWalker(candidate.element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
})() : [];
|
||||
const start = textRects.find((rect) => rect.width > 8 && rect.bottom > viewport.top && rect.top < viewport.bottom) ?? candidate?.rect;
|
||||
if (!start) return null;
|
||||
// Press mid-text rather than at the row's left edge. A left-edge caret
|
||||
// freezes the anchor at offset 0, so an upward drag legitimately copies
|
||||
// nothing from the anchor turn and the 20-turn count would depend only on
|
||||
// how far the focus lands. Mid-text also exercises the frozen anchor's
|
||||
// real character offset instead of the trivial row boundary.
|
||||
const startX = Math.min(start.right - 4, start.left + Math.max(start.width * 0.45, 60));
|
||||
return {
|
||||
start: { x: startX, y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
activate: { x: Math.min(start.right - 2, startX + 30), y: (Math.max(start.top, viewport.top) + Math.min(start.bottom, viewport.bottom)) / 2 },
|
||||
edge: { x: startX, y: viewport.top + 2 },
|
||||
anchorTurn: candidate.turn,
|
||||
};
|
||||
});
|
||||
assert(points != null, "bench transcript exposes a selectable visible message");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__transcriptProgrammaticWrites = [];
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
|
||||
window.__transcriptProgrammaticWrites.push({ owner, top });
|
||||
};
|
||||
});
|
||||
|
||||
await page.mouse.move(points.start.x, points.start.y);
|
||||
await page.mouse.down();
|
||||
const downState = await page.evaluate(({ x, y }) => ({
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
target: document.elementFromPoint(x, y)?.outerHTML.slice(0, 300),
|
||||
}), points.start);
|
||||
assert(downState.mode === "native-selecting", `primary pointerdown transfers scroll ownership to native selection (${downState.mode}; ${downState.target})`);
|
||||
await page.mouse.move(points.activate.x, points.activate.y, { steps: 6 });
|
||||
await page.waitForTimeout(50);
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
await page.mouse.wheel(0, -650);
|
||||
await page.mouse.move(points.edge.x, points.edge.y, { steps: 4 });
|
||||
await page.waitForTimeout(60);
|
||||
}
|
||||
await page.mouse.move(points.edge.x, points.edge.y);
|
||||
await page.waitForFunction(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return false;
|
||||
const max = transcript.scrollHeight - transcript.clientHeight;
|
||||
return max > 0 && transcript.scrollTop <= max * 0.3;
|
||||
}, undefined, { timeout: 30_000 });
|
||||
const neutralPoint = await page.evaluate(() => {
|
||||
const rect = document.querySelector(".transcript")?.getBoundingClientRect();
|
||||
return rect ? { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 } : null;
|
||||
});
|
||||
assert(neutralPoint != null, "deep logical drag keeps the transcript mounted");
|
||||
await page.mouse.move(neutralPoint.x, neutralPoint.y);
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = (transcript.scrollHeight - transcript.clientHeight) * 0.1;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForTimeout(300);
|
||||
// One extra turn of margin below the 20-turn contract: Virtuoso can still
|
||||
// be settling row heights after edge scrolling, so the caret may land one
|
||||
// row away from the measured target when the pointer move is delivered.
|
||||
const focusTargetTurn = Math.max(0, points.anchorTurn - 21);
|
||||
// Target one extra turn beyond the 20-turn contract: Virtuoso can still be
|
||||
// settling row heights after edge scrolling, so the caret may land one row
|
||||
// away from the measured target when the pointer move is delivered.
|
||||
const findLogicalFocusPoint = () => page.evaluate((targetTurn) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const root = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((element) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
const turn = element.textContent?.match(/\bbench turn (\d+):/);
|
||||
return turn && Number(turn[1]) <= targetTurn
|
||||
&& rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
});
|
||||
if (!root) return null;
|
||||
const rect = root.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + 8),
|
||||
y: (Math.max(rect.top, viewport.top) + Math.min(rect.bottom, viewport.bottom)) / 2,
|
||||
};
|
||||
}, Math.max(0, points.anchorTurn - 21));
|
||||
let logicalFocusPoint = null;
|
||||
for (let index = 0; index < 40 && !logicalFocusPoint; index += 1) {
|
||||
logicalFocusPoint = await findLogicalFocusPoint();
|
||||
if (!logicalFocusPoint) {
|
||||
await page.mouse.wheel(0, -250);
|
||||
await page.waitForTimeout(50);
|
||||
}
|
||||
}
|
||||
assert(logicalFocusPoint != null, "deep logical drag settles over a visible 20+ turn target");
|
||||
// Rows can shift between measuring the focus target and delivering the
|
||||
// pointer move. Re-derive the coordinates on every attempt so the caret
|
||||
// ends on a mounted selectable row and the overlay actually paints.
|
||||
let overlayPainted = false;
|
||||
for (let attempt = 0; attempt < 5 && !overlayPainted; attempt += 1) {
|
||||
await page.mouse.move(logicalFocusPoint.x + 24, logicalFocusPoint.y, { steps: 4 });
|
||||
await page.mouse.move(logicalFocusPoint.x, logicalFocusPoint.y, { steps: 8 });
|
||||
overlayPainted = await page.waitForFunction(
|
||||
() => document.querySelectorAll(".transcript-selection-overlay__rect").length > 0,
|
||||
undefined,
|
||||
{ timeout: 3_000 },
|
||||
).then(() => true, () => false);
|
||||
if (!overlayPainted) logicalFocusPoint = (await findLogicalFocusPoint()) ?? logicalFocusPoint;
|
||||
}
|
||||
assert(overlayPainted, "cross-page drag paints the logical selection overlay");
|
||||
|
||||
const during = await page.evaluate(({ x, y }) => {
|
||||
const selection = document.getSelection();
|
||||
const writes = window.__transcriptProgrammaticWrites ?? [];
|
||||
const transcript = document.querySelector(".transcript");
|
||||
const viewport = transcript?.getBoundingClientRect();
|
||||
const rowIndex = (node) => {
|
||||
const element = node instanceof Element ? node : node?.parentElement;
|
||||
const value = element?.closest(".transcript__row")?.dataset.index;
|
||||
return value == null ? null : Number(value);
|
||||
};
|
||||
const selectableRoots = [...document.querySelectorAll("[data-transcript-selectable]")];
|
||||
const visibleSelectableRows = selectableRoots
|
||||
.filter((root) => {
|
||||
const rect = root.getBoundingClientRect();
|
||||
return viewport && rect.height > 0 && rect.bottom > viewport.top && rect.top < viewport.bottom;
|
||||
})
|
||||
.map(rowIndex);
|
||||
const positiveRangeRows = selectableRoots
|
||||
.filter((root) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(root);
|
||||
return [...range.getClientRects()].some((rect) => rect.width > 0 && rect.height > 0);
|
||||
})
|
||||
.map(rowIndex);
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const caret = document.caretPositionFromPoint?.(x, y)?.offsetNode
|
||||
?? document.caretRangeFromPoint?.(x, y)?.startContainer;
|
||||
return {
|
||||
collapsed: selection?.isCollapsed ?? true,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
writeCount: writes.length,
|
||||
writeOwners: [...new Set(writes.map((write) => write.owner))],
|
||||
mode: transcript?.dataset.scrollMode,
|
||||
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
|
||||
scrollTop: transcript?.scrollTop ?? null,
|
||||
scrollHeight: transcript?.scrollHeight ?? null,
|
||||
clientHeight: transcript?.clientHeight ?? null,
|
||||
hitRow: rowIndex(hit),
|
||||
hitSelectable: Boolean(hit?.closest("[data-transcript-selectable]")),
|
||||
caretRow: rowIndex(caret),
|
||||
mountedSelectableRows: selectableRoots.map(rowIndex),
|
||||
visibleSelectableRows,
|
||||
positiveRangeRows,
|
||||
};
|
||||
}, logicalFocusPoint);
|
||||
assert(during.collapsed, `cross-row drag releases the browser Range after logical promotion (${JSON.stringify(during)})`);
|
||||
assert(during.mode === "logical-selecting", `cross-page drag remains owned by logical selection (${during.mode})`);
|
||||
assert(during.rows <= Math.ceil(baselineRows * 1.1) + 2, `logical selection keeps the virtual DOM bounded (${baselineRows} → ${during.rows})`);
|
||||
assert(during.overlayRects > 0, `logical selection paints mounted-row overlay rectangles (${JSON.stringify(during)})`);
|
||||
if (during.writeOwners.some((owner) => owner !== "selection-edge-scroll")) {
|
||||
throw new Error(`logical gesture admitted non-selection scroll owners: ${JSON.stringify(during.writeOwners)}`);
|
||||
}
|
||||
assert(true, "logical gesture rejects every non-selection programmatic scroll owner");
|
||||
|
||||
await page.mouse.up();
|
||||
await page.waitForTimeout(250);
|
||||
const settled = await page.evaluate(() => ({
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
|
||||
scrollTop: document.querySelector(".transcript")?.scrollTop ?? 0,
|
||||
}));
|
||||
assert(settled.mode === "manual", "pointerup settles logical selection without a delayed page jump");
|
||||
assert(settled.overlayRects > 0, "settled logical selection keeps its visible overlay");
|
||||
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = transcript.scrollHeight;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
await page.waitForTimeout(200);
|
||||
await page.evaluate((top) => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = top;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
}, settled.scrollTop);
|
||||
await page.waitForTimeout(250);
|
||||
const restoredRects = await page.locator(".transcript-selection-overlay__rect").count();
|
||||
assert(restoredRects > 0, "logical overlay restores after selected rows scroll out and back in");
|
||||
|
||||
await page.evaluate(() => {
|
||||
window.__logicalClipboardText = null;
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: { writeText: async (text) => { window.__logicalClipboardText = text; } },
|
||||
});
|
||||
});
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
|
||||
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
|
||||
const copied = await page.evaluate(() => window.__logicalClipboardText);
|
||||
const copiedTurnValues = [...copied.matchAll(/bench turn (\d+):/g)].map((match) => Number(match[1]));
|
||||
const copiedTurns = copiedTurnValues.length;
|
||||
assert(copiedTurns >= 20, `logical copy resolves a 20+ turn frozen snapshot (${copiedTurns} turns: ${copiedTurnValues.join(",")})`);
|
||||
|
||||
await page.waitForTimeout(100);
|
||||
const after = await page.evaluate(() => {
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined;
|
||||
return {
|
||||
collapsed: document.getSelection()?.isCollapsed ?? true,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
overlayRects: document.querySelectorAll(".transcript-selection-overlay__rect").length,
|
||||
};
|
||||
});
|
||||
assert(after.collapsed, "logical copy leaves no synthetic browser Range behind");
|
||||
assert(after.overlayRects === 0, "successful copy clears the logical overlay");
|
||||
assert(after.rows <= Math.ceil(baselineRows * 1.1) + 2, "clearing logical selection preserves the normal virtual DOM window");
|
||||
const selectionHeapBaseline = await retainedHeap();
|
||||
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
transcript.scrollTop = 0;
|
||||
transcript.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
// Query and scroll advancement are separate steps: advancing scrollTop in
|
||||
// the same evaluate as a missed query lets the scroll position run ahead of
|
||||
// Virtuoso's row mounting under load, skipping every selectable row. Give
|
||||
// each position two settle periods and wrap around at the bottom.
|
||||
let forwardPoints = null;
|
||||
for (let index = 0; index < 120 && !forwardPoints; index += 1) {
|
||||
await page.waitForTimeout(50);
|
||||
forwardPoints = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const textRects = [...transcript.querySelectorAll("[data-transcript-selectable]")].flatMap((element) => {
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
||||
const rects = [];
|
||||
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
||||
if (!node.textContent?.trim()) continue;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(node);
|
||||
rects.push(...range.getClientRects());
|
||||
}
|
||||
return rects;
|
||||
}).filter((rect) => rect.width > 8 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4);
|
||||
const start = textRects[0];
|
||||
if (!start) return null;
|
||||
const y = (Math.max(start.top, viewport.top + 4) + Math.min(start.bottom, viewport.bottom - 4)) / 2;
|
||||
return {
|
||||
start: { x: start.left + 2, y },
|
||||
activate: { x: Math.min(start.right - 2, start.left + 32), y },
|
||||
edge: { x: start.left + 2, y: viewport.bottom - 2 },
|
||||
};
|
||||
});
|
||||
if (!forwardPoints && index % 2 === 1) {
|
||||
await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return;
|
||||
const max = transcript.scrollHeight - transcript.clientHeight;
|
||||
transcript.scrollTop = transcript.scrollTop >= max - 4 ? 0 : transcript.scrollTop + transcript.clientHeight / 2;
|
||||
});
|
||||
}
|
||||
}
|
||||
assert(forwardPoints != null, "settled reverse selection leaves a viewport where forward selection can start");
|
||||
await page.evaluate(() => {
|
||||
window.__transcriptProgrammaticWrites = [];
|
||||
window.__logicalClipboardText = null;
|
||||
window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = (owner, top) => {
|
||||
window.__transcriptProgrammaticWrites.push({ owner, top });
|
||||
};
|
||||
});
|
||||
await page.mouse.move(forwardPoints.start.x, forwardPoints.start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(forwardPoints.activate.x, forwardPoints.activate.y, { steps: 6 });
|
||||
for (let index = 0; index < 60; index += 1) {
|
||||
await page.mouse.wheel(0, 500);
|
||||
await page.waitForTimeout(50);
|
||||
const visibleTarget = await page.evaluate(() => {
|
||||
const transcript = document.querySelector(".transcript");
|
||||
if (!transcript) return null;
|
||||
const viewport = transcript.getBoundingClientRect();
|
||||
const element = [...transcript.querySelectorAll("[data-transcript-selectable]")].find((candidate) => {
|
||||
const rect = candidate.getBoundingClientRect();
|
||||
return rect.width > 8 && rect.height > 0 && rect.bottom > viewport.top + 4 && rect.top < viewport.bottom - 4;
|
||||
});
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return {
|
||||
x: Math.min(rect.right - 2, rect.left + 8),
|
||||
y: (Math.max(rect.top, viewport.top + 4) + Math.min(rect.bottom, viewport.bottom - 4)) / 2,
|
||||
};
|
||||
});
|
||||
if (visibleTarget) {
|
||||
await page.mouse.move(visibleTarget.x, visibleTarget.y, { steps: 4 });
|
||||
} else {
|
||||
await page.mouse.move(
|
||||
forwardPoints.edge.x + (index % 2 === 0 ? 2 : -2),
|
||||
forwardPoints.edge.y - (index % 2),
|
||||
{ steps: 4 },
|
||||
);
|
||||
}
|
||||
const mode = await page.locator(".transcript").getAttribute("data-scroll-mode");
|
||||
if (mode === "logical-selecting") break;
|
||||
}
|
||||
await page.mouse.move(forwardPoints.edge.x, forwardPoints.edge.y);
|
||||
await page.waitForTimeout(8_000);
|
||||
const forwardDuring = await page.evaluate(() => ({
|
||||
mode: document.querySelector(".transcript")?.dataset.scrollMode,
|
||||
rows: document.querySelectorAll(".transcript__row").length,
|
||||
owners: [...new Set((window.__transcriptProgrammaticWrites ?? []).map((write) => write.owner))],
|
||||
}));
|
||||
assert(forwardDuring.mode === "logical-selecting", "downward cross-page drag also promotes to logical selection");
|
||||
assert(forwardDuring.rows <= Math.ceil(baselineRows * 1.1) + 2, "forward logical selection also keeps the virtual DOM bounded");
|
||||
assert(forwardDuring.owners.every((owner) => owner === "selection-edge-scroll"), "forward logical gesture preserves scroll ownership");
|
||||
await page.mouse.up();
|
||||
await page.keyboard.press(process.platform === "darwin" ? "Meta+C" : "Control+C");
|
||||
await page.waitForFunction(() => typeof window.__logicalClipboardText === "string", undefined, { timeout: 30_000 });
|
||||
const forwardCopiedTurns = await page.evaluate(() => (window.__logicalClipboardText.match(/bench turn /g) ?? []).length);
|
||||
assert(forwardCopiedTurns >= 20, `forward logical copy resolves a 20+ turn frozen snapshot (${forwardCopiedTurns} turns)`);
|
||||
await page.waitForFunction(() => document.querySelectorAll(".transcript-selection-overlay__rect").length === 0);
|
||||
const retainedSelectionBytes = Math.max(0, (await retainedHeap()) - selectionHeapBaseline);
|
||||
assert(retainedSelectionBytes <= 2 * 1024 * 1024, `cleared logical selection retains at most 2MiB (${(retainedSelectionBytes / 1024 / 1024).toFixed(2)}MiB)`);
|
||||
await page.evaluate(() => { window.__REASONIX_TRANSCRIPT_SCROLL_WRITE__ = undefined; });
|
||||
} finally {
|
||||
await browser?.close();
|
||||
preview.kill("SIGTERM");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: "off",
|
||||
},
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "module",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
},
|
||||
rules: {
|
||||
"react-hooks/rules-of-hooks": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||